Compare commits

...

215 Commits

Author SHA1 Message Date
Ammar Ahmed
8de433cee1 mobile: finalize settings ui 2026-06-19 13:52:01 +05:00
Ammar Ahmed
ee1c88b7cf mobile: add inbox api 2026-06-18 12:13:57 +05:00
Ammar Ahmed
d6a5e27eb4 mobile: add app-lock password change screens 2026-06-18 09:23:08 +05:00
Ammar Ahmed
318f500fc7 mobile: settings ui update 2026-06-18 08:37:53 +05:00
Ammar Ahmed
f2616468fa mobile: fix errors 2026-06-16 15:41:30 +05:00
Ammar Ahmed
dd3489272c mobile: complete settings module 2026-06-16 15:23:02 +05:00
Ammar Ahmed
3b1dd47fc8 mobile: update behavior settings 2026-06-16 15:21:20 +05:00
Ammar Ahmed
386155caa6 mobile: update theme-selector and notesnook circle ui 2026-06-16 15:20:52 +05:00
Ammar Ahmed
2260bc774a mobile: replace hard coded strings 2026-06-16 15:02:59 +05:00
Ammar Ahmed
de491c0015 mobile: add force sync sheets 2026-06-16 15:02:57 +05:00
Ammar Ahmed
5fadb95ddd mobile: update 2fa sheets design 2026-06-16 15:02:36 +05:00
Ammar Ahmed
9c5cf1a5ec mobile: update change-password & recovery-key sheets 2026-06-16 15:02:09 +05:00
Ammar Ahmed
03af07ce33 mobile: update basic settings ui and reorganize settings screen 2026-06-16 14:58:39 +05:00
Ammar Ahmed
3112c6466f mobile: ui fixes 2026-06-16 14:42:41 +05:00
Ammar Ahmed
2c0f7b8f8f mobile: fix two-factor ui 2026-06-16 14:41:40 +05:00
Ammar Ahmed
5bb4dfedd7 mobile: fix Login screen text capitalization and eye icon mismatch with design 2026-06-16 14:39:49 +05:00
Ammar Ahmed
a6a7c60035 mobile: fix text capitalization 2026-06-16 14:39:47 +05:00
Ammar Ahmed
ce44bda0bc mobile: update ui 2026-06-16 14:38:17 +05:00
Ammar Ahmed
a934277c98 mobile: update ui 2026-06-16 14:35:29 +05:00
Ammar Ahmed
4e32106aa9 mobile: update ui for paywall flow 2026-06-16 14:31:52 +05:00
Ammar Ahmed
1d5119dde9 mobile: update onboarding screen ui 2026-06-16 14:25:32 +05:00
Ammar Ahmed
1e73a511c1 mobile: fix password placeholders 2026-06-16 13:43:17 +05:00
Ammar Ahmed
8fee146a32 mobile: undo hardcoded settings.introCompleted. 2026-06-16 13:43:17 +05:00
Ammar Ahmed
ff3570bae8 mobile: fix button color 2026-06-16 13:43:17 +05:00
Ammar Ahmed
7ffaab036b mobile: ui fixes 2026-06-16 13:43:16 +05:00
Ammar Ahmed
73d2fe1271 mobile: fix setting up account svg colors in light mode 2026-06-16 13:42:09 +05:00
Ammar Ahmed
b6ace863b3 mobile: minor adjustments 2026-06-16 13:42:09 +05:00
Ammar Ahmed
912fa3fe49 mobile: make intro illustration work with light mode 2026-06-16 13:42:09 +05:00
Ammar Ahmed
394c12856b mobile: minor ui fixes 2026-06-16 13:42:09 +05:00
Ammar Ahmed
1473a70681 mobile: support dark/light mode on paywall logos 2026-06-16 13:42:09 +05:00
Ammar Ahmed
6e4d5490bc mobile: show setup account screen after signup 2026-06-16 13:42:09 +05:00
Ammar Ahmed
1a864743d0 mobile: fix pin input recovery code 2026-06-16 13:42:09 +05:00
Ammar Ahmed
e87a9297ce mobile: fix two-factor ui 2026-06-16 13:42:07 +05:00
Ammar Ahmed
72e2010074 mobile: fix button and text alignment in compare plans screen 2026-06-16 13:14:44 +05:00
Ammar Ahmed
5d151045cd mobile: fix login allowed without entering password field 2026-06-16 13:14:44 +05:00
Ammar Ahmed
60d7208696 mobile: fix Login screen text capitalization and eye icon mismatch with design 2026-06-16 13:14:43 +05:00
Ammar Ahmed
882a667269 mobile: fix Resend code button not working on 2FA screen 2026-06-16 13:14:19 +05:00
Ammar Ahmed
7583e16152 mobile: update intl strings 2026-06-16 13:14:19 +05:00
Ammar Ahmed
02f537e296 common: match feature titles with design 2026-06-16 13:14:19 +05:00
Ammar Ahmed
a462e5a10e mobile: fix Extra punctuation in “Setting up your account” screen text 2026-06-16 13:14:19 +05:00
Ammar Ahmed
800c1ccd4d mobile: hide progress pills in setting up your account if flow is not introductory 2026-06-16 13:14:19 +05:00
Ammar Ahmed
33880c682a mobile: fix wording if user already has an account 2026-06-16 13:14:19 +05:00
Ammar Ahmed
a2cd14ffc8 mobile: fix signup input secure entry icon 2026-06-16 13:14:19 +05:00
Ammar Ahmed
7ba5e72f64 mobile: fix text capitalization 2026-06-16 13:14:17 +05:00
Ammar Ahmed
0e5b216773 mobile: fix colors in signup 2026-06-16 13:13:55 +05:00
Ammar Ahmed
ee43a020b2 mobile: fix signup footer text and position 2026-06-16 13:13:54 +05:00
Ammar Ahmed
364225b34b mobile: fix illustration colors 2026-06-16 13:13:54 +05:00
Ammar Ahmed
cb862e7293 mobile: fix errors 2026-06-16 13:13:52 +05:00
Ammar Ahmed
247a2824e8 mobile: update ui 2026-06-16 13:12:20 +05:00
Ammar Ahmed
51dceb9715 mobile: update form input styles 2026-06-16 13:12:18 +05:00
Ammar Ahmed
a0ba1fb1d2 mobile: updated colors 2026-06-16 13:09:49 +05:00
Ammar Ahmed
7ae1cc85ac theme: add new variant & colors 2026-06-16 13:09:49 +05:00
Ammar Ahmed
67d25797d6 mobile: update svg logos in paywall 2026-06-16 13:09:49 +05:00
Ammar Ahmed
b3b00fc7cf mobile: enable repack commands 2026-06-16 13:09:49 +05:00
Ammar Ahmed
ba253db226 mobile: fix ts errors 2026-06-16 13:09:49 +05:00
Ammar Ahmed
9db7795e31 mobile: cleanup 2026-06-16 13:09:49 +05:00
Ammar Ahmed
526487c37e mobile: update ui 2026-06-16 13:09:48 +05:00
Ammar Ahmed
37e7d44587 mobile: update ui for paywall flow 2026-06-16 13:04:51 +05:00
Ammar Ahmed
2e09a691e7 mobile: update ui for auth screens 2026-06-16 13:03:46 +05:00
Ammar Ahmed
1c8e43d085 mobile: update onboarding screen ui 2026-06-16 13:03:44 +05:00
Ammar Ahmed
6ee2e777c2 mobile: add style guide baseline 2026-06-16 13:02:58 +05:00
Ammar Ahmed
528812d69d Merge branch 'master' into beta 2026-06-16 12:54:30 +05:00
Ammar Ahmed
ab5391fd6e Merge pull request #9971 from streetwriters/fix/331-358
fix date picker modal renders blank if themVariant is not set based on app's theme mode
2026-06-16 12:39:22 +05:00
Ammar Ahmed
770d290af1 Merge pull request #9892 from kashaf-ansari-dev/fix-remove-unset-expiry-date
mobile: fix unset note expiry date
2026-06-16 12:38:22 +05:00
kashaf-ansari-dev
3db6c2b1fd mobile: remove today/tomorrow logic and set default expiry initial date to 1 week
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-16 12:35:58 +05:00
kashaf-ansari-dev
19a11dae26 mobile: fix expiry date picker width
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-16 12:35:58 +05:00
kashaf-ansari-dev
07d7a537e4 mobile: display expiry date on notes item
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-16 12:35:58 +05:00
kashaf-ansari-dev
20f39b82c6 mobile: add success messages on setting or removing expiry date
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-16 12:35:58 +05:00
kashaf-ansari-dev
9e5ed67eb3 mobile: prevent crash in note properties when expiryDate is undefined
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-16 12:34:53 +05:00
kashaf-ansari-dev
6ddd5078ac mobile: fix unset note expiry date
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-16 12:34:53 +05:00
Ammar Ahmed
579ae294b6 Merge pull request #9955 from kashaf-ansari-dev/fix-published-note-updated-title
mobile: reflected published note title in published section
2026-06-16 12:26:25 +05:00
Ammar Ahmed
6418476ed6 Merge pull request #9932 from kashaf-ansari-dev/fix-display-monograph-view-count
mobile: display monograph view count
2026-06-16 12:25:47 +05:00
Ammar Ahmed
dfc01fa558 Merge pull request #9931 from kashaf-ansari-dev/fix-vault-dialog-form-input
mobile: replace Input with FormInput in VaultDialog for inline field …
2026-06-16 12:24:09 +05:00
01zulfi
b4f3593966 web: fix recovery view header overflow on small screens (#9975)
* web: fix recovery view header overflow on small screens
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: fix long emails being truncated in recovery screen
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-15 20:16:50 +05:00
Abdullah Atta
c0eff80bd4 webclipper: bump version to 0.4.1 (#9977) 2026-06-13 11:22:53 +05:00
Ammar Ahmed
fd55eddf73 mobile: update version codes 2026-06-13 10:42:46 +05:00
Abdullah Atta
80a964af67 Release 3.4.0-beta.1 (#9885)
* web: bump version to 3.4.0-beta.1

* mobile: bump version

* mobile: hide inbox settings when inbox api is disabled

* mobile: fix failed inbox item text goes outside the view

* mobile: add validation for creating api key name input

* mobile: fix api key shows expired status if set to never expire

* mobile: fix typo Notesbook to Notesnook

* mobile: add missing setup inbox keys screen

* mobile: update linkedom version

* mobile: fix ts errors

* mobile: show error on both inputs in case of invalid keypair

* mobile: fix expired text not visible in light mode

* mobile: show loading in switch item when disabling inbox api

* mobile: enable save button only if keys are edited

* mobile: make failed inbox item error json vertically scrollable

* mobile: fix inbox pgp keys title text mismatch

* mobile: ask user password before showing pgp keys

* mobile: show inbox keys saved success message when inbox api created

* intl: cleanup

* mobile: show generic error below input fields

* mobile: show full date-time on inbox api keys

* mobile: update inbox enabled setting on userFetched event
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* core: add extensive logging around sync

* mobile: fix scrollview in sheet

---------

Co-authored-by: Ammar Ahmed <ammarahmed6506@gmail.com>
Co-authored-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
Co-authored-by: Ammar Ahmed <40239442+ammarahm-ed@users.noreply.github.com>
2026-06-13 10:28:43 +05:00
Ammar Ahmed
8c3c011ad0 mobile: fix scrollview in sheet 2026-06-13 10:27:49 +05:00
Abdullah Atta
632d923c69 core: add extensive logging around sync 2026-06-13 09:23:38 +05:00
Ammar Ahmed
ce03e01bf2 mobile: fix date picker modal renders blank if themVariant is not set based on app's theme mode 2026-06-11 20:19:16 +05:00
Ammar Ahmed
e2aa57909f Merge pull request #9969 from streetwriters/mobile/live-inbox-enabled
mobile: update inbox enabled setting on userFetched event
2026-06-11 10:27:04 +05:00
01zulfi
6c9eb57f3d mobile: update inbox enabled setting on userFetched event
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-11 09:56:04 +05:00
Ammar Ahmed
e68d60588e mobile: show full date-time on inbox api keys 2026-06-11 09:27:06 +05:00
Ammar Ahmed
49aa7860f8 mobile: show generic error below input fields 2026-06-11 09:27:06 +05:00
Ammar Ahmed
9c6cfa0b27 intl: cleanup 2026-06-11 09:27:06 +05:00
Ammar Ahmed
4d75a55f33 mobile: show inbox keys saved success message when inbox api created 2026-06-11 09:26:47 +05:00
Ammar Ahmed
e1e2aec014 mobile: ask user password before showing pgp keys 2026-06-11 09:26:47 +05:00
Ammar Ahmed
e82b141aaf mobile: fix inbox pgp keys title text mismatch 2026-06-11 09:26:47 +05:00
Ammar Ahmed
db53a1e311 mobile: make failed inbox item error json vertically scrollable 2026-06-11 09:26:47 +05:00
Ammar Ahmed
3216c33fe6 mobile: enable save button only if keys are edited 2026-06-11 09:26:47 +05:00
Ammar Ahmed
d2ca64d7c9 mobile: show loading in switch item when disabling inbox api 2026-06-11 09:26:47 +05:00
Ammar Ahmed
91a56058bb mobile: fix expired text not visible in light mode 2026-06-11 09:26:47 +05:00
Ammar Ahmed
4e057fa788 mobile: show error on both inputs in case of invalid keypair 2026-06-11 09:26:47 +05:00
Ammar Ahmed
229edaae55 mobile: fix ts errors 2026-06-11 09:26:47 +05:00
Ammar Ahmed
eb265ea89e mobile: update linkedom version 2026-06-11 09:26:47 +05:00
Ammar Ahmed
16ee34aceb mobile: add missing setup inbox keys screen 2026-06-11 09:26:47 +05:00
Ammar Ahmed
8dd39e6a68 mobile: fix typo Notesbook to Notesnook 2026-06-11 09:25:50 +05:00
Ammar Ahmed
d8bc12287c mobile: fix api key shows expired status if set to never expire 2026-06-11 09:25:50 +05:00
Ammar Ahmed
5ba3233055 mobile: add validation for creating api key name input 2026-06-11 09:25:50 +05:00
Ammar Ahmed
ffecb2f9cb mobile: fix failed inbox item text goes outside the view 2026-06-11 09:25:50 +05:00
Ammar Ahmed
bfd3b0736d mobile: hide inbox settings when inbox api is disabled 2026-06-11 09:25:50 +05:00
Ammar Ahmed
26b9dc1149 mobile: bump version 2026-06-11 09:25:50 +05:00
Abdullah Atta
1a6c752c57 web: bump version to 3.4.0-beta.1 2026-06-11 09:25:33 +05:00
01zulfi
c7e0225535 core: handle triggerSync & inboxUpdated SSEs (#9963)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-11 09:23:56 +05:00
kashaf-ansari-dev
0cb527f97b mobile: clear password error when switching locked notes
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-10 22:19:46 +05:00
Abdullah Atta
9c103ea0e3 Merge branch 'master' into beta 2026-06-10 17:39:44 +05:00
kashaf-ansari-dev
c89a9fad50 mobile: fix biometric password update flow
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-10 09:03:09 +05:00
kashaf-ansari-dev
251e635ccb core: handle note open/unlock using try/catch
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-10 09:03:09 +05:00
kashaf-ansari-dev
46d5bc83c8 core: don't recreate vault on note open/remove after vault deletion
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-10 09:03:09 +05:00
kashaf-ansari-dev
9c69514a74 mobile: reset crendentials after deleting vault
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-10 09:03:09 +05:00
kashaf-ansari-dev
a6a74b924e mobile: show inline password error on note unlock instead of toast
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-10 09:03:09 +05:00
kashaf-ansari-dev
8c8f085b16 mobile: remove unused validationType from FormInput
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-10 09:03:08 +05:00
kashaf-ansari-dev
f75ce74c6b mobile: replace Input with FormInput in VaultDialog for inline field validation
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-10 09:03:08 +05:00
kashaf-ansari-dev
d7e92346e5 mobile: reflected published note title in published section
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-10 07:56:09 +05:00
kashaf-ansari-dev
17bbaa2323 mobile: prevent UI jerk on analytics refresh after app resume
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-09 20:09:09 +05:00
kashaf-ansari-dev
cdef17eb8d mobile: fix monograpghs analytics fetch and display conditions
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-05 12:33:02 +05:00
kashaf-ansari-dev
4256003e1c mobile: refresh monograph analytics on app foreground
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-05 12:32:46 +05:00
kashaf-ansari-dev
657f0e428a mobile: display monograph view count
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-05 12:29:59 +05:00
Abdullah Atta
967374d28e Merge pull request #9526 from streetwriters/fix/desktop-app-security
Improve desktop app security
2026-06-02 15:56:54 +05:00
Abdullah Atta
adaaabe46c ci: install arm64 sqlite3-fts5-html 2026-06-02 12:27:26 +05:00
Abdullah Atta
8b2f5901fe web: increase real time sync test timeout 2026-05-30 11:16:10 +05:00
Abdullah Atta
d64d2e44d9 web: make backup tests more reliable 2026-05-30 11:10:47 +05:00
Abdullah Atta
4f8c9eb76c web: fix tests 2026-05-30 11:00:35 +05:00
Abdullah Atta
ba0cfbf44a desktop: move file link confirm dialog to native side 2026-05-30 11:00:35 +05:00
Abdullah Atta
b43374c53f desktop: fix typescript build output 2026-05-30 11:00:35 +05:00
Abdullah Atta
f2c08c89b0 desktop: add support for running web tests via desktop app 2026-05-30 11:00:35 +05:00
Abdullah Atta
bd04427e03 desktop: secure desktop app by disabling nodeIntegration & enabling contextIsolation
this required significant infrastructure changes to how we do things. It is no longer possible to access anything outside of what the app exposes. Requiring node modules has been disabled which should make things A LOT more secure. File system related APIs have also been removed and now everything sensitive is handled on the Node.js side of things. The renderer does not control paths, fs, or anything that could be used to exploit the host system.
2026-05-30 11:00:35 +05:00
Abdullah Atta
27ca64694f web: bring back file:// links opening support 2026-05-30 11:00:35 +05:00
Abdullah Atta
42f229ab03 Merge branch 'master' into beta 2026-05-30 11:00:07 +05:00
Abdullah Atta
cad13856af Merge branch 'master' into beta 2026-05-23 10:28:40 +05:00
Ammar Ahmed
3248c82c5c Merge branch 'master' into beta 2026-05-21 13:44:51 +05:00
Abdullah Atta
691a0a62ea Merge pull request #9829 from streetwriters/web/failed-inbox-table
web: handle & show failed inbox items
2026-05-21 13:13:27 +05:00
01zulfi
2c578c9695 web: add delete all button in inbox history dialog
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-21 12:25:59 +05:00
Ammar Ahmed
ee48990c90 mobile: show delete-all button only when items are in list 2026-05-21 12:15:28 +05:00
Ammar Ahmed
436c635889 mobile: add failed inbox item history 2026-05-21 11:55:44 +05:00
Ammar Ahmed
51b226226e core: fix invalid html results in empty note
When html is invalid we want to return it as-is in a code-block so it gets rendered correctly
2026-05-21 11:54:45 +05:00
01zulfi
7deb095aca web: inbox UI fixes
* use user format preferences for dates in inbox module
* delete all failed inbox items when inbox is disabled
* improve error message for failed validation case

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-21 11:54:45 +05:00
01zulfi
b615dfcd87 web: handle & show failed inbox items
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-21 11:54:43 +05:00
01zulfi
833368e225 web: fix never expiring inbox api key being shown as expired (#9856)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-21 11:48:04 +05:00
Ammar Ahmed
9221e21393 mobile: fix inbox keys validation (#9882) 2026-05-21 11:44:25 +05:00
01zulfi
0476d3652a core: use RequestFetchV4 method (#9873)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-19 13:38:52 +05:00
Abdullah Atta
e4c132a8cc monograph: bump version to 1.3.1 2026-05-18 21:21:02 +05:00
Abdullah Atta
ba9877b3dd monograph: bump bun version to 1.3.14 2026-05-18 21:20:52 +05:00
01zulfi
a545c9cfac core: require non-empty source field in inbox item (#9868)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-18 09:52:25 +05:00
Ammar Ahmed
fa03ec5ebe Merge branch 'master' into beta 2026-05-15 12:53:44 +05:00
01zulfi
79ecbf6752 core: clear key cache & fix private key encoding in saveInboxKeys() (#9851)
* after saving new keys, we didn't clear the cache so getInboxKeys() was still returning old keys
* remove unnecessary JSON.stringify on private key in saveInboxKeys()

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-15 11:34:37 +05:00
01zulfi
2d97445f26 web: show correct message when user provided pgp private key is encrypted (#9850)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-15 08:23:16 +05:00
Chloe Oletto
4f805910c4 web: Don't show input box in "too many Inbox keys" dialog. (#9817)
Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>
2026-05-06 08:20:55 +05:00
Ammar Ahmed
e6a75c1f8a Merge branch 'master' into beta 2026-05-05 20:07:31 +05:00
Abdullah Atta
6489dda4c8 Merge branch 'master' into beta 2026-04-22 11:15:34 +05:00
Ammar Ahmed
0b1aa3315b Merge branch 'master' into beta 2026-04-18 10:08:55 +05:00
Chloe Oletto
ebfc554cd7 web: fix Beta release track unset after browser exit (#9721)
Beta cookies are set to expire after the session ends, which means update checks in the future (for example, the user puts their computer to sleep, but leaves the web app open) can erroneously suggest the latest stable on the beta release track.

Signed-off By: Chloe Oletto <NeedsChloesure@riseup.net>
2026-04-17 15:49:17 +05:00
Abdullah Atta
cf3f2fc3ce monograph: bump version to 1.3.0 2026-04-15 08:45:19 +05:00
Ammar Ahmed
a62fea1a4b mobile: release 3.4.0-beta.0 2026-04-15 08:43:49 +05:00
Abdullah Atta
f83e894758 web: bump version to 3.4.0-beta.0 2026-04-15 08:14:29 +05:00
Abdullah Atta
131f6c6d5f Merge branch 'master' into beta 2026-04-15 08:10:24 +05:00
Abdullah Atta
534cf883be Merge pull request #9395 from streetwriters/web/vault-lock-timer-setting
web: add vault lock timer setting
2026-04-15 08:03:47 +05:00
01zulfi
8a83a1af23 web: fix vault lock timer not showing after vault is created
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-14 13:44:55 +05:00
01zulfi
690c30eed3 web: show toast when vault is auto-locked or when timer is changed
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-14 12:44:26 +05:00
Ammar Ahmed
8cc86284ee mobile: fix ts errors 2026-04-13 12:43:32 +05:00
Ammar Ahmed
e4f1f2bfa7 core: fix import 2026-04-13 11:43:33 +05:00
Ammar Ahmed
1f7a64a42d mobile: add vault lock timer settings 2026-04-13 11:40:34 +05:00
01zulfi
2b1cd537e2 web: add vault lock timer setting
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-13 11:40:34 +05:00
Abdullah Atta
769d92fa0c docs(help): add beta warning for inbox api 2026-04-08 09:51:37 +05:00
01zulfi
f6d5d3cebc web: enable inbox && add inbox api docs (#8872)
* web: enable inbox && add inbox api docs
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* docs(help): improve inbox api docs

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-04-08 09:49:00 +05:00
Abdullah Atta
f3ef277a52 Merge pull request #9643 from streetwriters/core/validate-inbox-items
core: validate & sanitize inbox items
2026-04-07 20:41:25 +05:00
Abdullah Atta
d30ec4a19d core: make html validation more resilient 2026-04-07 20:34:39 +05:00
Ammar Ahmed
206157b68b core: handle invalid html inputs 2026-04-07 12:02:23 +05:00
Abdullah Atta
418e4517a5 Merge pull request #8468 from 01zulfi/web/note-spellcheck-toggle
web: add support for toggling spellcheck per note
2026-04-06 23:20:44 +05:00
Abdullah Atta
9bd0f0ceb1 editor: fix minor issues 2026-04-06 23:19:19 +05:00
Ammar Ahmed
61a56662eb core: make dom-purify mobile compatible 2026-04-06 14:29:41 +05:00
Ammar Ahmed
6efd1af0e9 Feat/image compression setting (#9542)
* mobile: migrate pickers to ts

* mobile: add support for image compression default setting in mobile

* mobile: update e2e tests
2026-04-06 11:24:09 +05:00
01zulfi
8189b2ead8 web: remove spellcheck toggle from note context menu
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-06 09:58:04 +05:00
01zulfi
257ac8962c web: refresh sessions instead of raising event on spellcheck toggle
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-06 09:57:51 +05:00
01zulfi
faea6635c6 core: remove unnecessary note.spellcheck data migration
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-06 09:57:42 +05:00
Ammar Ahmed
537521d568 mobile: add spellcheck toggle in note properties 2026-04-06 09:57:42 +05:00
01zulfi
e3ed75461e web: spellcheck should be enabled by default for new notes && hide properties toggle if disabled on desktop
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-06 09:57:19 +05:00
01zulfi
f6b59eeb99 web: add support for toggling spellcheck per note
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-06 09:57:02 +05:00
Abdullah Atta
b58db58e13 Merge pull request #9506 from streetwriters/editor/internal-link-title-in-popup
editor: show internal link's title in hover popup
2026-04-06 09:37:31 +05:00
Ammar Ahmed
195dc18a09 Merge pull request #9649 from streetwriters/feat/copy-id-mobile
mobile: add copy id button in notebook and tag properties
2026-04-06 09:28:44 +05:00
Ammar Ahmed
187086460d mobile: undo debug change 2026-04-06 09:28:01 +05:00
Abdullah Atta
84655cec40 Merge pull request #9650 from streetwriters/fix-internal-links
Fix internal links
2026-04-06 09:25:29 +05:00
01zulfi
49b1dd46d1 core: add support for server controlled monograph urls (#8995)
* core: support monograph.publishUrl field
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* core: support monograph publish-info endpoint
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* core: pubilsh-info -> metadata

* monograph: add support for slug

* mobile: use monograph metadata to get publishUrl (#9628)

Signed-off-by: sahil <sahil.aslam011@gmail.com>

* core: use v2 endpoint for publishing monographs

* monograph: separate route for v2 monographs

* core: fix monograph updates

---------

Signed-off-by: sahil <sahil.aslam011@gmail.com>
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
Co-authored-by: SahilAslam001 <sahil.aslam011@gmail.com>
2026-04-06 09:15:15 +05:00
01zulfi
76e14d4434 web: handle multiple internal link types in internal link properties tabs
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-03 14:40:58 +05:00
Ammar Ahmed
f828ab0314 mobile: safely handle multiple internal link types 2026-04-03 13:01:34 +05:00
Ammar Ahmed
8d3d173762 core: db.notes.internalLinks() should only return note internal links 2026-04-03 13:00:51 +05:00
Ammar Ahmed
12644fce28 mobile: add copy id button in notebook and tag properties 2026-04-03 12:20:44 +05:00
Ammar Ahmed
358b499d32 editor: add support for pasting nn:// internal urls 2026-04-02 14:02:24 +05:00
Ammar Ahmed
17864d3325 mobile: add support in editor to fetch internal link data 2026-04-02 13:32:03 +05:00
01zulfi
7b6165c8f0 core: validate & sanitize inbox items
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-02 13:26:36 +05:00
Ammar Ahmed
c90a2f5179 Merge branch 'master' into beta 2026-04-02 13:07:58 +05:00
01zulfi
47b2569b29 editor: add loading indicator while fetching internal link title
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-03-31 16:12:20 +05:00
Abdullah Atta
692975d4a0 Merge pull request #8928 from streetwriters/web/inbox-pgp-keys
[NN-5] web: use pgp keys for inbox
2026-03-31 13:32:30 +05:00
Abdullah Atta
6a843a6457 core: fix getInboxKeys 2026-03-31 13:31:37 +05:00
Abdullah Atta
a139bdad1e intl: update strings 2026-03-31 13:31:37 +05:00
Ammar Ahmed
b5dc298d7d mobile: add inbox api settings 2026-03-31 13:31:37 +05:00
01zulfi
561477fb1a web: use pgp keys for inbox
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-03-31 13:30:48 +05:00
01zulfi
543766d564 editor: support internal link titles for all available entities
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-03-31 13:17:53 +05:00
01zulfi
9789edad4c editor: show internal link's note title in hover popup
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-03-30 17:12:51 +05:00
Abdullah Atta
b46e02753a Merge pull request #9378 from streetwriters/desktop/handle-nn-protocol
Add handler for nn:// urls
2026-03-30 12:07:42 +05:00
Ammar Ahmed
5f74fcad0c Merge branch 'master' into beta 2026-03-30 09:44:11 +05:00
Ammar Ahmed
34b5005591 Merge branch 'master' into beta 2026-03-28 09:59:09 +05:00
Ammar Ahmed
2b0a1c6a78 Merge pull request #9552 from SahilAslam001/fet-close-all-tab
mobile: add close button in editor all tab bottom sheet  #6059
2026-03-28 09:12:15 +05:00
Ammar Ahmed
fcba5f9c91 mobile: fix editor stays open when opening notebook/tag/color link. 2026-03-27 11:41:47 +05:00
Ammar Ahmed
5909025976 mobile: add support for internal links 2026-03-27 11:38:51 +05:00
sahil
c2702efc7f mobile: change icon
Signed-off-by: Sahil Aslam <sahil.aslam011@gmail.com>
2026-03-26 15:55:00 +05:00
sahil
c96d13d416 mobile: fix icon and function name
Signed-off-by: Sahil Aslam <sahil.aslam011@gmail.com>
2026-03-26 14:28:27 +05:00
Ammar Ahmed
3d52a307ca Merge branch 'master' into beta 2026-03-26 12:20:46 +05:00
sahil
32c825463f mobile: add close button in editor all tab bottom sheet #6059
Signed-off-by: Sahil Aslam <sahil.aslam011@gmail.com>
2026-03-20 11:34:01 +05:00
Abdullah Atta
1172a8c67b web: fix events missed from desktop side on startup 2026-03-19 09:32:40 +05:00
Abdullah Atta
0191e5be00 desktop: wait for bridge to be ready before sending events 2026-03-19 09:32:07 +05:00
Abdullah Atta
7f367389bb web: support copying notebook/tag/color internal link 2026-03-19 09:24:39 +05:00
Abdullah Atta
e94c757d5b desktop: add handler for nn:// urls 2026-03-18 11:39:26 +05:00
Abdullah Atta
29e213ebe2 Merge branch 'master' into beta 2026-03-14 11:16:27 +05:00
Abdullah Atta
8eb7663f37 Merge branch 'master' into beta 2026-03-10 09:55:37 +05:00
553 changed files with 44261 additions and 23663 deletions

View File

@@ -146,6 +146,7 @@ jobs:
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu x64 sqlite-better-trigram
npm i --cpu x64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Build Electron wrapper
@@ -189,6 +190,7 @@ jobs:
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu arm64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Build Electron wrapper

51
agent.md Normal file
View File

@@ -0,0 +1,51 @@
## Notesnook Design Agent
### Purpose
- Implement Figma designs from user-provided links into the Notesnook app with high visual fidelity.
- Use related components and files provided by the user as the source of truth for local patterns.
- Preserve behavior, navigation, and existing data flow while updating UI.
### When To Use This Agent
- Use this agent when the task is design-to-code for app UI.
- Prefer this agent over a general coding agent when the user provides a Figma link and asks for redesign implementation.
### Scope
- Primary focus: apps/mobile.
- Secondary scope: shared UI dependencies needed for the design update (tokens, icons, strings, and generated assets).
- Do not perform unrelated refactors.
### Tool Preferences
- Prefer Figma design-context tools to extract layout, spacing rhythm, typography intent, and asset references from the exact node.
- Prefer direct workspace edits and targeted validation for touched files.
- Avoid introducing new UI frameworks or web-specific styling approaches.
### Repo Conventions
- Use theme and token systems first: useThemeColors, spacing/radius constants, and shared typography primitives.
- Reuse existing UI primitives and patterns before creating new abstractions.
- Keep dark and light mode compatibility by default.
- Keep changes minimal, isolated, and review-friendly.
### Icon Conventions
- Add new icon SVGs to packages/icons/svgs with lowercase kebab-case names.
- Regenerate icon assets from apps/mobile using npx react-native-nano-icons.
- Use generated icon names through AppIcon with icon family notesnook where applicable.
### Figma To Code Workflow
1. Read the exact Figma node from the provided link.
2. Compare against existing repo patterns and referenced components.
3. Implement with React Native conventions already used in the app.
4. Preserve behavior and interactions unless explicitly changed.
5. Validate compile and lint state for edited files.
### Output Expectations
- Provide a concise summary of what changed.
- List modified files and any generated artifacts.
- Call out follow-up steps only when needed.

View File

@@ -17,155 +17,92 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { testCleanup, test } from "./test-override.js";
import { writeFile } from "fs/promises";
import { Page } from "playwright";
import { test } from "@nn/test";
import { gt, lt } from "semver";
import { describe } from "vitest";
import { AppModel } from "../../web/__e2e__/models/app.model.js";
import { expect } from "playwright/test";
test("update starts downloading if version is outdated", async ({
ctx: { page },
expect,
onTestFinished
}) => {
onTestFinished(testCleanup);
test.extend({ options: { version: "3.0.0" } })(
"update starts downloading if version is outdated",
async ({ page }) => {
await page.waitForSelector(".ProseMirror");
await page.waitForSelector("#authForm");
expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /updating/i })
.waitFor({ state: "attached" });
}
);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
await page.waitForSelector(".ProseMirror");
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /updating/i })
.waitFor({ state: "attached" });
});
test("update is only shown if version is outdated and auto updates are disabled", async ({
ctx,
expect,
onTestFinished
}) => {
onTestFinished(testCleanup);
await ctx.app.close();
await writeFile(
ctx.configPath,
JSON.stringify({
test.extend({
options: {
version: "3.0.0",
config: {
automaticUpdates: false
})
);
}
}
})(
"update is only shown if version is outdated and auto updates are disabled",
async ({ page }) => {
await page.waitForSelector(".ProseMirror");
await ctx.relaunch();
const app = new AppModel(page);
const settings = await app.goToSettings();
const { page } = ctx;
await settings.checkForUpdates();
await settings.close();
await page.waitForSelector("#authForm");
expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /available/i })
.waitFor({ state: "attached" });
}
);
test.extend({
options: {
version: "3.0.0-beta.0",
config: {
automaticUpdates: false,
releaseTrack: "beta"
}
}
})("update to stable if it is newer", async ({ page }) => {
await page.waitForSelector(".ProseMirror");
await page
const app = new AppModel(page);
const settings = await app.goToSettings();
await settings.checkForUpdates();
await settings.close();
const updateButton = page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /available/i })
.waitFor({ state: "attached" });
.getByRole("button", { name: /available/i });
await updateButton.waitFor({ state: "visible" });
const content = await updateButton.textContent();
const version = content?.split(" ")?.[0] || "";
expect(gt(version, "3.0.0-beta.0")).toBe(true);
});
describe("update to stable if it is newer", () => {
test.scoped({ options: { version: "3.0.0-beta.0" } });
test("test", async ({ ctx, expect, onTestFinished }) => {
onTestFinished(testCleanup);
await ctx.app.close();
await writeFile(
ctx.configPath,
JSON.stringify({
automaticUpdates: false,
releaseTrack: "beta"
})
);
await ctx.relaunch();
const { page } = ctx;
await page.waitForSelector("#authForm");
expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
test.extend({
options: {
version: "99.0.0-beta.0",
config: {
automaticUpdates: false,
releaseTrack: "beta"
}
}
})(
"update is not available if it latest stable version is older",
async ({ page }) => {
await page.waitForSelector(".ProseMirror");
const updateButton = page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /available/i });
await updateButton.waitFor({ state: "visible" });
const content = await updateButton.textContent();
const version = content?.split(" ")?.[0] || "";
expect(gt(version, "3.0.0-beta.0")).toBe(true);
});
});
const app = new AppModel(page);
const settings = await app.goToSettings();
describe("update is not available if it latest stable version is older", () => {
test.scoped({ options: { version: "99.0.0-beta.0" } });
test("test", async ({ ctx, expect, onTestFinished }) => {
onTestFinished(testCleanup);
await ctx.app.close();
await writeFile(
ctx.configPath,
JSON.stringify({
automaticUpdates: false,
releaseTrack: "beta"
})
);
await ctx.relaunch();
const { page } = ctx;
await page.waitForSelector("#authForm");
expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
await page.waitForSelector(".ProseMirror");
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /checking for updates/i })
.waitFor({ state: "hidden" });
await settings.checkForUpdates();
await settings.close();
expect(
await page
@@ -173,45 +110,24 @@ describe("update is not available if it latest stable version is older", () => {
.getByRole("button", { name: /available/i })
.isHidden()
).toBe(true);
});
});
describe("downgrade to stable on switching to stable release track", () => {
test.scoped({ options: { version: "99.0.0-beta.0" } });
test("test", async ({ ctx, expect, onTestFinished }) => {
onTestFinished(testCleanup);
await ctx.app.close();
await writeFile(
ctx.configPath,
JSON.stringify({
automaticUpdates: false,
releaseTrack: "stable"
})
);
await ctx.relaunch();
const { page } = ctx;
await page.waitForSelector("#authForm");
expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
await skipDialog(page);
}
);
test.extend({
options: {
version: "99.0.0-beta.0",
config: { automaticUpdates: false, releaseTrack: "stable" }
}
})(
"downgrade to stable on switching to stable release track",
async ({ page }) => {
await page.waitForSelector(".ProseMirror");
await page
.locator(".theme-scope-statusBar")
.getByRole("button", { name: /checking for updates/i })
.waitFor({ state: "hidden" });
const app = new AppModel(page);
const settings = await app.goToSettings();
await settings.checkForUpdates();
await settings.close();
const updateButton = page
.locator(".theme-scope-statusBar")
@@ -220,23 +136,5 @@ describe("downgrade to stable on switching to stable release track", () => {
const content = await updateButton.textContent();
const version = content?.split(" ")?.[0] || "";
expect(lt(version, "99.0.0-beta.0")).toBe(true);
});
});
async function skipDialog(page: Page) {
try {
const dialog = page.locator(".ReactModal__Content");
const positiveButton = dialog.locator(
"button[data-role='positive-button']"
);
const negativeButton = dialog.locator(
"button[data-role='negative-button']"
);
if (await positiveButton.isVisible())
await positiveButton.click({ timeout: 1000 });
else if (await negativeButton.isVisible())
await negativeButton.click({ timeout: 1000 });
} catch (e) {
// ignore error
}
}
);

View File

@@ -0,0 +1,64 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { mergeTests } from "@playwright/test";
import { test } from "@playwright/test";
import type { CommonFixtures, CommonWorkerFixtures } from "./common-fixtures";
import { commonFixtures } from "./common-fixtures";
import { platformTest } from "./platform-fixtures";
import { testModeTest } from "./test-mode-fixtures";
export const base = test;
export const baseTest = mergeTests(base, platformTest, testModeTest).extend<
CommonFixtures,
CommonWorkerFixtures
>(commonFixtures);
export function step<
This extends NonNullable<unknown>,
Args extends any[],
Return
>(
target: (this: This, ...args: Args) => Promise<Return>,
context: ClassMethodDecoratorContext<
This,
(this: This, ...args: Args) => Promise<Return>
>
) {
function replacementMethod(this: This, ...args: Args): Promise<Return> {
const name =
this.constructor.name +
"." +
(context.name as string) +
"(" +
args.map((a) => JSON.stringify(a)).join(",") +
")";
return test.step(name, async () => {
return await target.call(this, ...args);
});
}
return replacementMethod;
}
// declare global {
// interface Window {
// builtins: Builtins;
// }
// }

View File

@@ -0,0 +1,347 @@
/* eslint-disable header/header */
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Fixtures } from "@playwright/test";
import type { ChildProcess } from "child_process";
import { execSync, spawn } from "child_process";
import net from "net";
import fs from "fs";
import { stripAnsi } from "./playwright-utils";
type TestChildParams = {
command: string[];
cwd?: string;
env?: NodeJS.ProcessEnv;
shell?: boolean;
onOutput?: () => void;
};
import childProcess from "child_process";
type ProcessData = {
pid: number; // process ID
pgrp: number; // process group ID
children: Set<ProcessData>; // direct children of the process
};
function readAllProcessesLinux(): {
pid: number;
ppid: number;
pgrp: number;
}[] {
const result: { pid: number; ppid: number; pgrp: number }[] = [];
for (const dir of fs.readdirSync("/proc")) {
const pid = +dir;
if (isNaN(pid)) continue;
try {
const statFile = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
// Format of /proc/*/stat is described https://man7.org/linux/man-pages/man5/proc.5.html
const match = statFile.match(
/^(?<pid>\d+)\s+\((?<comm>.*)\)\s+(?<state>R|S|D|Z|T|t|W|X|x|K|W|P)\s+(?<ppid>\d+)\s+(?<pgrp>\d+)/
);
if (match && match.groups) {
result.push({
pid: +match.groups.pid,
ppid: +match.groups.ppid,
pgrp: +match.groups.pgrp
});
}
} catch (e) {
// We don't have access to some /proc/<pid>/stat file.
}
}
return result;
}
function readAllProcessesMacOS(): {
pid: number;
ppid: number;
pgrp: number;
}[] {
const result: { pid: number; ppid: number; pgrp: number }[] = [];
const processTree = childProcess.spawnSync("ps", ["-eo", "pid,pgid,ppid"]);
const lines = processTree.stdout.toString().trim().split("\n");
for (const line of lines) {
const [pid, pgrp, ppid] = line
.trim()
.split(/\s+/)
.map((token) => +token);
// On linux, the very first line of `ps` is the header with "PID PGID PPID".
if (isNaN(pid) || isNaN(pgrp) || isNaN(ppid)) continue;
result.push({ pid, ppid, pgrp });
}
return result;
}
function buildProcessTreePosix(pid: number): ProcessData | undefined {
// Certain Linux distributions might not have `ps` installed.
const allProcesses =
process.platform === "darwin"
? readAllProcessesMacOS()
: readAllProcessesLinux();
const pidToProcess = new Map<number, ProcessData>();
for (const { pid, pgrp } of allProcesses)
pidToProcess.set(pid, { pid, pgrp, children: new Set() });
for (const { pid, ppid } of allProcesses) {
const parent = pidToProcess.get(ppid);
const child = pidToProcess.get(pid);
// On POSIX, certain processes might not have parent (e.g. PID=1 and occasionally PID=2)
// or we might not have access to it proc info.
if (parent && child) parent.children.add(child);
}
return pidToProcess.get(pid);
}
export class TestChildProcess {
params: TestChildParams;
process: ChildProcess;
output = "";
stdout = "";
stderr = "";
fullOutput = "";
onOutput?: (chunk: string | Buffer) => void;
exited: Promise<{ exitCode: number | null; signal: string | null }>;
exitCode: Promise<number | null>;
private _outputCallbacks = new Set<() => void>();
constructor(params: TestChildParams) {
this.params = params;
// See https://nodejs.org/api/deprecations.html#DEP0190
const command = params.shell ? params.command.join(" ") : params.command[0];
const args = params.shell ? [] : params.command.slice(1);
this.process = spawn(command, args, {
env: {
...process.env,
...params.env
},
cwd: params.cwd,
shell: params.shell,
// On non-windows platforms, `detached: true` makes child process a leader of a new
// process group, making it possible to kill child process tree with `.kill(-pid)` command.
// @see https://nodejs.org/api/child_process.html#child_process_options_detached
detached: process.platform !== "win32"
});
if (process.env.PWTEST_DEBUG)
process.stdout.write(`\n\nLaunching ${params.command.join(" ")}\n`);
this.onOutput = params.onOutput;
const appendChunk = (type: "stdout" | "stderr", chunk: string | Buffer) => {
this.output += String(chunk);
if (type === "stderr") this.stderr += String(chunk);
else this.stdout += String(chunk);
if (process.env.PWTEST_DEBUG) process.stdout.write(String(chunk));
else this.fullOutput += String(chunk);
this.onOutput?.(chunk);
for (const cb of this._outputCallbacks) cb();
this._outputCallbacks.clear();
};
this.process.stderr!.on("data", appendChunk.bind(null, "stderr"));
this.process.stdout!.on("data", appendChunk.bind(null, "stdout"));
const killProcessGroup = this._killProcessTree.bind(this, "SIGKILL");
process.on("exit", killProcessGroup);
this.exited = new Promise((f) => {
this.process.on("exit", (exitCode, signal) => f({ exitCode, signal }));
process.off("exit", killProcessGroup);
});
this.exitCode = this.exited.then((r) => r.exitCode);
}
outputLines(): string[] {
const strippedOutput = stripAnsi(this.output);
return strippedOutput
.split("\n")
.filter((line) => line.startsWith("%%"))
.map((line) => line.substring(2).trim());
}
async kill(signal: "SIGINT" | "SIGKILL" = "SIGKILL") {
this._killProcessTree(signal);
return this.exited;
}
private _killProcessTree(signal: "SIGINT" | "SIGKILL") {
if (!this.process.pid || !this.process.kill(0)) return;
killProcessGroup(this.process.pid, signal);
}
async cleanExit() {
const r = await this.exited;
if (r.exitCode)
throw new Error(
`Process failed with exit code ${r.exitCode}. Output:\n${this.output}`
);
if (r.signal)
throw new Error(
`Process received signal: ${r.signal}. Output:\n${this.output}`
);
}
async waitForOutput(substring: string, count = 1) {
while (countTimes(stripAnsi(this.output), substring) < count)
await new Promise<void>((f) => this._outputCallbacks.add(f));
}
clearOutput() {
this.output = "";
}
write(chars: string) {
this.process.stdin!.write(chars);
}
}
export function killProcessGroup(
pid: number,
signal: "SIGINT" | "SIGKILL" = "SIGKILL"
) {
// On Windows, we always call `taskkill` no matter signal.
if (process.platform === "win32") {
try {
execSync(`taskkill /pid ${pid} /T /F /FI "MEMUSAGE gt 0"`, {
stdio: "ignore"
});
} catch (e) {
// the process might have already stopped
}
return;
}
// In case of POSIX and `SIGINT` signal, send it to the main process group only.
if (signal === "SIGINT") {
try {
process.kill(-pid, "SIGINT");
} catch (e) {
// the process might have already stopped
}
return;
}
// In case of POSIX and `SIGKILL` signal, we should send it to all descendant process groups.
const rootProcess = buildProcessTreePosix(pid);
if (!rootProcess) return;
const descendantProcessGroups = (function flatten(
processData: ProcessData,
result: Set<number> = new Set()
) {
// Process can nullify its own process group with `setpgid`. Use its PID instead.
result.add(processData.pgrp || processData.pid);
processData.children.forEach((child) => flatten(child, result));
return result;
})(rootProcess);
for (const pgrp of descendantProcessGroups) {
try {
process.kill(-pgrp, "SIGKILL");
} catch (e) {
// the process might have already stopped
}
}
}
export type CommonFixtures = {
childProcess: (params: TestChildParams) => TestChildProcess;
waitForPort: (port: number) => Promise<void>;
findFreePort: () => Promise<number>;
};
export type CommonWorkerFixtures = {
daemonProcess: (params: TestChildParams) => TestChildProcess;
};
export const commonFixtures: Fixtures<CommonFixtures, CommonWorkerFixtures> = {
childProcess: async ({}, use, testInfo) => {
const processes: TestChildProcess[] = [];
await use((params) => {
const process = new TestChildProcess(params);
processes.push(process);
return process;
});
await Promise.all(processes.map(async (child) => child.kill()));
if (
testInfo.status !== "passed" &&
testInfo.status !== "skipped" &&
!process.env.PWTEST_DEBUG
) {
for (const process of processes) {
console.log("====== " + process.params.command.join(" "));
console.log(process.fullOutput.replace(/\x1Bc/g, ""));
console.log("=========================================");
}
}
},
daemonProcess: [
async ({}, use) => {
const processes: TestChildProcess[] = [];
await use((params) => {
const process = new TestChildProcess(params);
processes.push(process);
return process;
});
await Promise.all(processes.map((child) => child.kill("SIGINT")));
},
{ scope: "worker" }
],
waitForPort: async ({}, use) => {
const token = { canceled: false };
await use(async (port) => {
while (!token.canceled) {
const promise = new Promise<boolean>((resolve) => {
const conn = net
.connect(port, "127.0.0.1")
.on("error", () => resolve(false))
.on("connect", () => {
conn.end();
resolve(true);
});
});
if (await promise) return;
await new Promise((x) => setTimeout(x, 100));
}
});
token.canceled = true;
},
findFreePort: async ({}, use) => {
await use(async () => {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const { port } = server.address() as net.AddressInfo;
server.close(() => resolve(port));
});
server.on("error", reject);
});
});
}
};
export function countTimes(s: string, sub: string): number {
let result = 0;
for (let index = 0; index !== -1; ) {
index = s.indexOf(sub, index);
if (index !== -1) {
result++;
index += sub.length;
}
}
return result;
}

View File

@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { buildApp } from "./utils";
export default async function setup() {
export default async function globalSetup() {
console.log("Building the app...");
await buildApp();
}

View File

@@ -0,0 +1,152 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/* eslint-disable no-empty-pattern */
import { AppContext, buildAndLaunchApp, TestOptions } from "./utils";
import path from "path";
import { version } from "../../package.json";
import type { Browser, ElectronApplication, Page } from "@playwright/test";
import type { TraceViewerFixtures } from "./trace-viewer-fixtures";
import { traceViewerFixtures } from "./trace-viewer-fixtures";
import { PageTestFixtures, PageWorkerFixtures } from "./page-test-api";
import fs from "fs";
import { tmpdir } from "os";
import { baseTest } from "./base-test";
export type ElectronTestFixtures = PageTestFixtures & {
electronApp: ElectronApplication;
launchElectronApp: (options?: TestOptions) => Promise<ElectronApplication>;
createUserDataDir: () => Promise<string>;
options: TestOptions;
newPage: () => Promise<Page>;
};
export type { Page, Browser } from "@playwright/test";
export { expect } from "@playwright/test";
export const test = baseTest
.extend<TraceViewerFixtures>(traceViewerFixtures)
.extend<ElectronTestFixtures, PageWorkerFixtures>({
browserVersion: [
({}, use) => use(process.env.ELECTRON_CHROMIUM_VERSION!),
{ scope: "worker" }
],
browserMajorVersion: [
({}, use) =>
use(Number(process.env.ELECTRON_CHROMIUM_VERSION!.split(".")[0])),
{ scope: "worker" }
],
electronMajorVersion: [
({}, use) =>
use(
parseInt(require("electron/package.json").version.split(".")[0], 10)
),
{ scope: "worker" }
],
isBidi: [false, { scope: "worker" }],
isAndroid: [false, { scope: "worker" }],
isElectron: [true, { scope: "worker" }],
isHeadlessShell: [false, { scope: "worker" }],
isFrozenWebkit: [false, { scope: "worker" }],
createUserDataDir: async ({}, run) => {
const dirs: string[] = [];
// We do not put user data dir in testOutputPath,
// because we do not want to upload them as test result artifacts.
await run(async () => {
const dir = await fs.promises.mkdtemp(
path.join(tmpdir(), "playwright-test-")
);
dirs.push(dir);
return dir;
});
await removeFolders(dirs);
},
launchElectronApp: async ({ createUserDataDir }, use) => {
// This env prevents 'Electron Security Policy' console message.
process.env["ELECTRON_DISABLE_SECURITY_WARNINGS"] = "true";
const apps: AppContext[] = [];
await use(async (options?: TestOptions) => {
const userDataDir = await createUserDataDir();
const ctx = await buildAndLaunchApp(
userDataDir,
options || {
version,
config: {}
}
);
apps.push(ctx);
return ctx.app;
});
for (const ctx of apps) {
await ctx.app.close();
}
await removeFolders(apps.map((ctx) => ctx.outputDir));
},
electronApp: async ({ launchElectronApp, options }, use) => {
await use(await launchElectronApp(options));
},
page: async ({ electronApp, viewport }, run) => {
const page = await electronApp.firstWindow();
if (viewport) {
await page.setViewportSize(viewport);
await electronApp.evaluate((p, viewport) => {
const mainWindow = p.BrowserWindow.getAllWindows()[0];
mainWindow.setSize(viewport.width, viewport.height);
}, viewport);
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
page.context().app = electronApp;
await run(page);
},
context: async ({ electronApp }, run) => {
await run(electronApp.context());
},
newPage: async ({ launchElectronApp, options }, use) => {
await use(async () => {
const app = await launchElectronApp(options);
return app.firstWindow();
});
},
options: async ({}, use) => {
await use({
version,
config: {}
});
}
});
async function removeFolders(folders: string[]) {
await Promise.all(
folders.map((folder) =>
fs.promises.rm(folder, {
force: true,
recursive: true,
maxRetries: 3,
retryDelay: 5000
})
)
);
}

View File

@@ -0,0 +1,58 @@
/* eslint-disable header/header */
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Browser, Page, ViewportSize } from "playwright-core";
import type {
PageScreenshotOptions,
ScreenshotMode,
VideoMode
} from "@playwright/test";
export { expect } from "@playwright/test";
// Page test does not guarantee an isolated context, just a new page (because Android).
export type PageTestFixtures = {
page: Page;
};
export type PageWorkerFixtures = {
headless: boolean;
channel: string | undefined;
screenshot:
| ScreenshotMode
| ({ mode: ScreenshotMode } & Pick<
PageScreenshotOptions,
"fullPage" | "omitBackground"
>);
trace:
| "off"
| "on"
| "retain-on-failure"
| "on-first-retry"
| "retain-on-first-failure"
| "on-all-retries"
| /** deprecated */ "retry-with-trace";
video: VideoMode | { mode: VideoMode; size: ViewportSize };
browserName: "chromium" | "firefox" | "webkit";
browserVersion: string;
browserMajorVersion: number;
electronMajorVersion: number;
isBidi: boolean;
isAndroid: boolean;
isElectron: boolean;
isHeadlessShell: boolean;
isFrozenWebkit: boolean;
};

View File

@@ -0,0 +1,47 @@
/* eslint-disable header/header */
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { test } from "@playwright/test";
import os from "os";
export type PlatformWorkerFixtures = {
platform: "win32" | "darwin" | "linux";
isWindows: boolean;
isMac: boolean;
isLinux: boolean;
macVersion: number; // major only, 11 or later, zero if not mac
};
function platform(): "win32" | "darwin" | "linux" {
if (process.env.PLAYWRIGHT_SERVICE_OS === "linux") return "linux";
if (process.env.PLAYWRIGHT_SERVICE_OS === "windows") return "win32";
if (process.env.PLAYWRIGHT_SERVICE_OS === "macos") return "darwin";
return process.platform as "win32" | "darwin" | "linux";
}
function macVersion() {
if (process.platform !== "darwin") return 0;
return +os.release().split(".")[0] - 9;
}
export const platformTest = test.extend<{}, PlatformWorkerFixtures>({
platform: [platform(), { scope: "worker" }],
isWindows: [platform() === "win32", { scope: "worker" }],
isMac: [platform() === "darwin", { scope: "worker" }],
isLinux: [platform() === "linux", { scope: "worker" }],
macVersion: [macVersion(), { scope: "worker" }]
});

View File

@@ -0,0 +1,26 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
const ansiRegex = new RegExp(
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))",
"g"
);
export function stripAnsi(str: string): string {
return str.replace(ansiRegex, "");
}

View File

@@ -0,0 +1,75 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test } from "@playwright/test";
import type { TestModeName } from "./test-mode";
import { DefaultTestMode, DriverTestMode } from "./test-mode";
export type TestModeWorkerOptions = {
mode: TestModeName;
};
export type TestModeTestFixtures = {
toImpl: (rpcObject?: any) => any;
};
export type TestModeWorkerFixtures = {
toImplInWorkerScope: (rpcObject?: any) => any;
playwright: typeof import("@playwright/test");
};
export const testModeTest = test.extend<
TestModeTestFixtures,
TestModeWorkerOptions & TestModeWorkerFixtures
>({
mode: ["default", { scope: "worker", option: true }],
playwright: [
async ({ mode }, run) => {
const testMode = {
default: new DefaultTestMode(),
service: new DefaultTestMode(),
service2: new DefaultTestMode(),
"service-grid": new DefaultTestMode(),
wsl: new DefaultTestMode(),
driver: new DriverTestMode()
}[mode];
const playwright = await testMode.setup();
await run(playwright);
await testMode.teardown();
},
{ scope: "worker" }
],
toImplInWorkerScope: [
async ({ playwright }, use) => {
await use((playwright as any)._connection.toImpl);
},
{ scope: "worker" }
],
toImpl: async (
{ toImplInWorkerScope: toImplWorker, mode },
use,
testInfo
) => {
if (mode !== "default" || process.env.PW_TEST_REUSE_CONTEXT)
testInfo.skip();
await use(toImplWorker);
}
});

View File

@@ -0,0 +1,59 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-ignore
import { start } from "playwright-core/lib/outofprocess";
// @ts-ignore
import type { Playwright } from "playwright-core/lib/client/playwright";
export type TestModeName =
| "default"
| "driver"
| "service"
| "service2"
| "wsl";
interface TestMode {
setup(): Promise<Playwright>;
teardown(): Promise<void>;
}
export class DriverTestMode implements TestMode {
private _impl: { playwright: Playwright; stop: () => Promise<void> };
async setup() {
this._impl = await start({
NODE_OPTIONS: undefined // Hide driver process while debugging.
});
return this._impl.playwright;
}
async teardown() {
await this._impl.stop();
}
}
export class DefaultTestMode implements TestMode {
async setup() {
return require("playwright-core");
}
async teardown() {}
}

View File

@@ -0,0 +1,243 @@
/* eslint-disable header/header */
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {
Fixtures,
FrameLocator,
Locator,
Page,
Browser,
BrowserContext
} from "@playwright/test";
import { step } from "./base-test";
import path from "path";
import { CommonFixtures, TestChildProcess } from "./common-fixtures";
type BaseTestFixtures = CommonFixtures & {
context: BrowserContext;
};
type BaseWorkerFixtures = {
headless: boolean;
browser: Browser;
browserName: "chromium" | "firefox" | "webkit";
playwright: typeof import("@playwright/test");
};
export type TraceViewerFixtures = {
showTraceViewer: (
trace: string | undefined,
options?: { host?: string; port?: number; stdin?: boolean }
) => Promise<TraceViewerPage>;
runAndTrace: (
body: () => Promise<void>,
optsOverrides?: Parameters<BrowserContext["tracing"]["start"]>[0]
) => Promise<TraceViewerPage>;
};
class TraceViewerPage {
actionTitles: Locator;
actionsTree: Locator;
callLines: Locator;
consoleLines: Locator;
logLines: Locator;
errorMessages: Locator;
consoleLineMessages: Locator;
consoleStacks: Locator;
networkRequests: Locator;
metadataTab: Locator;
snapshotContainer: Locator;
sourceCodeTab: Locator;
networkTab: Locator;
settingsDialog: Locator;
themeSetting: Locator;
displayCanvasContentSetting: Locator;
constructor(public page: Page, public process: TestChildProcess) {
this.actionTitles = page.locator(".action-title");
this.actionsTree = page.getByTestId("actions-tree");
this.callLines = page.locator(".call-tab .call-line");
this.logLines = page
.getByRole("list", { name: "Log entries" })
.getByRole("listitem");
this.consoleLines = page
.getByRole("tabpanel", { name: "Console" })
.getByRole("listitem");
this.consoleLineMessages = page.locator(".console-line-message");
this.errorMessages = page.locator(".error-message");
this.consoleStacks = page.locator(".console-stack");
this.networkRequests = page
.getByRole("list", { name: "Network requests" })
.getByRole("listitem");
this.snapshotContainer = page.locator(
".snapshot-container iframe.snapshot-visible[name=snapshot]"
);
this.metadataTab = page.getByRole("tabpanel", { name: "Metadata" });
this.sourceCodeTab = page.getByRole("tabpanel", { name: "Source" });
this.networkTab = page.getByRole("tabpanel", { name: "Network" });
this.settingsDialog = page.getByTestId("settings-toolbar-dialog");
this.themeSetting = this.settingsDialog.getByRole("combobox", {
name: "Theme"
});
this.displayCanvasContentSetting = page
.locator(".setting")
.getByText("Display canvas content");
}
@step
async showAllActions() {
await this.page.getByRole("button", { name: "Filter actions" }).click();
await this.page.locator(".setting").getByText("Network routes").click();
await this.page.locator(".setting").getByText("Getters").click();
await this.page.locator(".setting").getByText("Configuration").click();
await this.page.getByRole("button", { name: "Filter actions" }).click();
}
stackFrames(options: { selected?: boolean } = {}) {
const entry = this.page
.getByRole("list", { name: "Stack trace" })
.getByRole("listitem");
if (options.selected) return entry.locator(":scope.selected");
return entry;
}
actionIconsText(action: string) {
const entry = this.actionsTree.getByRole("treeitem", { name: action });
return entry.locator(".action-icon-value").filter({ visible: true });
}
actionIcons(action: string) {
return this.actionsTree
.getByRole("treeitem", { name: action })
.locator(".action-icons")
.filter({ visible: true });
}
@step
async expandAction(title: string) {
await this.actionsTree
.getByRole("treeitem", { name: title })
.locator(".codicon-chevron-right")
.click();
}
@step
async selectAction(title: string, ordinal: number = 0) {
await this.actionsTree.getByTitle(title).nth(ordinal).click();
}
@step
async hoverAction(title: string, ordinal: number = 0) {
await this.actionsTree
.getByRole("treeitem", { name: title })
.nth(ordinal)
.hover();
}
@step
async selectSnapshot(name: string) {
await this.page.getByRole("tab", { name }).click();
}
async showErrorsTab() {
await this.page.getByRole("tab", { name: "Errors" }).click();
}
async showConsoleTab() {
await this.page.getByRole("tab", { name: "Console" }).click();
}
async showSourceTab() {
await this.page.getByRole("tab", { name: "Source" }).click();
}
async showNetworkTab() {
await this.page.getByRole("tab", { name: "Network" }).click();
}
async showMetadataTab() {
await this.page.getByRole("tab", { name: "Metadata" }).click();
}
async showSettings() {
await this.page.getByRole("button", { name: "Settings" }).click();
}
@step
async snapshotFrame(
actionName: string,
ordinal: number = 0,
hasSubframe: boolean = false
): Promise<FrameLocator> {
await this.selectAction(actionName, ordinal);
while (this.page.frames().length < (hasSubframe ? 4 : 3))
await this.page.waitForEvent("frameattached");
return this.page.frameLocator("iframe.snapshot-visible[name=snapshot]");
}
}
export const traceViewerFixtures: Fixtures<
TraceViewerFixtures,
{},
BaseTestFixtures,
BaseWorkerFixtures
> = {
showTraceViewer: async ({ playwright, childProcess, browserName }, use) => {
const browsers: Browser[] = [];
await use(async (trace: string | undefined, { host, port, stdin } = {}) => {
const command = [
"node",
path.join(__dirname, "../../node_modules/playwright-core/cli.js"),
"show-trace",
"--port",
"" + (port ?? "0")
];
if (host) command.push("--host", host);
if (stdin) command.push("--stdin");
if (trace) command.push(trace);
const cp = childProcess({ command });
await cp.waitForOutput("Listening on");
const browser = await playwright.chromium.launch({
...(browserName === "chromium" ? {} : { channel: "chromium" }),
executablePath: process.env.CRPATH // without this, setting FFPATH makes us launch Firefox with Chromium args
});
browsers.push(browser);
const page = await browser.newPage();
const url = cp.output.match(/Listening on (http:\/\/[^\s]+)/)![1];
await page.goto(url);
return new TraceViewerPage(page, cp);
});
for (const browser of browsers) await browser.close();
},
runAndTrace: async ({ context, showTraceViewer }, use, testInfo) => {
await use(async (body: () => Promise<void>, optsOverrides = {}) => {
const traceFile = testInfo.outputPath("trace.zip");
await context.tracing.start({
snapshots: true,
screenshots: true,
sources: true,
...optsOverrides
});
await body();
await context.tracing.stop({ path: traceFile });
return showTraceViewer(traceFile);
});
}
};

View File

@@ -19,21 +19,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { execSync } from "child_process";
import { cp } from "fs/promises";
import { fileURLToPath } from "node:url";
import path, { join, resolve } from "path";
import { _electron as electron } from "playwright";
import { _electron as electron, ElectronApplication, Page } from "playwright";
import { existsSync } from "fs";
import { mkdir, writeFile } from "node:fs/promises";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const IS_DEBUG = process.env.NN_DEBUG === "true" || process.env.CI === "true";
const productName = `NotesnookTestHarness`;
const SOURCE_DIR = resolve("output", productName);
const root = path.resolve(__dirname, "..", "..");
const SOURCE_DIR = resolve(root, "output", productName);
export interface AppContext {
app: import("playwright").ElectronApplication;
page: import("playwright").Page;
configPath: string;
userDataDir: string;
outputDir: string;
relaunch: () => Promise<void>;
@@ -41,6 +38,7 @@ export interface AppContext {
export interface TestOptions {
version: string;
config?: Record<string, unknown>;
}
export interface Fixtures {
@@ -49,35 +47,41 @@ export interface Fixtures {
}
export async function buildAndLaunchApp(
userDataDir: string,
options?: TestOptions
): Promise<AppContext> {
await buildApp(options?.version);
const productName = `notesnooktest${makeid(10)}`;
const outputDir = path.join("test-artifacts", `${productName}-output`);
const outputDir = path.join(root, "test-artifacts", `${productName}-output`);
const executablePath = await copyBuild({
...options,
outputDir
});
const { app, page, configPath, userDataDir } = await launchApp(
const configPath = path.join(userDataDir, "UserData", "config.json");
if (options?.config) {
await mkdir(path.dirname(configPath), { recursive: true });
await writeFile(configPath, JSON.stringify(options.config));
}
const { app } = await launchApp(
executablePath,
productName,
userDataDir,
options?.version
);
const ctx: AppContext = {
app,
page,
configPath,
userDataDir,
outputDir,
relaunch: async () => {
const { app, page, configPath, userDataDir } = await launchApp(
const { app } = await launchApp(
executablePath,
productName,
userDataDir,
options?.version
);
ctx.app = app;
ctx.page = page;
ctx.userDataDir = userDataDir;
ctx.configPath = configPath;
}
};
return ctx;
@@ -85,19 +89,14 @@ export async function buildAndLaunchApp(
async function launchApp(
executablePath: string,
packageName: string,
userDataDir: string,
version?: string
) {
const userDataDir = resolve(
__dirname,
"..",
"test-artifacts",
"user_data_dirs",
packageName
);
const app = await electron.launch({
executablePath,
args: IS_DEBUG ? [] : ["--hidden"],
baseURL: "https://app.notesnook.com",
acceptDownloads: true,
env: {
...(process.platform === "linux"
? {
@@ -114,20 +113,28 @@ async function launchApp(
}
});
const page = await app.firstWindow();
const configPath = path.join(userDataDir, "UserData", "config.json");
return {
app,
page,
configPath,
userDataDir
};
}
export function getAppFromPage(page: Page) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return page.context().app as ElectronApplication;
}
let MAX_RETRIES = 3;
export async function buildApp(version?: string) {
if (!existsSync(SOURCE_DIR)) {
execSync(
`node ${path.join(root, "scripts", "build.mjs")} --test --rebuild`,
{
stdio: IS_DEBUG ? "inherit" : "ignore"
}
);
const args = [
"electron-builder",
"--dir",
@@ -151,7 +158,7 @@ export async function buildApp(version?: string) {
});
} catch (e) {
if (--MAX_RETRIES) {
console.log("retrying...");
console.log("retrying...", e);
return await buildApp(version);
} else throw e;
}
@@ -189,8 +196,7 @@ async function makeBuildCopyMacOS(outputDir: string, productName: string) {
const platformDir = process.arch === "arm64" ? "mac-arm64" : "mac";
const appDir = await makeBuildCopy(outputDir, platformDir);
return resolve(
__dirname,
"..",
root,
appDir,
`${productName}.app`,
"Contents",

View File

@@ -17,24 +17,7 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { testCleanup, test } from "./test-override.js";
test("make sure app loads", async ({
ctx: { page },
expect,
onTestFinished
}) => {
onTestFinished(testCleanup);
await page.waitForSelector("#authForm");
expect(
await page.getByRole("button", { name: "Create account" }).isVisible()
).toBe(true);
await page
.getByRole("button", { name: "Skip & go directly to the app" })
.click();
import { test } from "@nn/test";
test("make sure app loads", async ({ page }) => {
await page.waitForSelector(".ProseMirror");
});

View File

@@ -1,64 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { test as vitestTest, TestContext } from "vitest";
import { buildAndLaunchApp, Fixtures, TestOptions } from "./utils";
import { mkdir, rm } from "fs/promises";
import path from "path";
import slugify from "slugify";
export const test = vitestTest.extend<Fixtures>({
options: { version: "3.0.0" } as TestOptions,
ctx: async ({ options }, use) => {
const ctx = await buildAndLaunchApp(options);
await use(ctx);
}
});
export async function testCleanup(context: TestContext) {
const ctx = (context.task.context as unknown as Fixtures).ctx;
if (context.task.result?.state === "fail") {
await mkdir("test-results", { recursive: true });
await ctx.page.screenshot({
path: path.join(
"test-results",
`${slugify(context.task.name)}-${process.platform}-${
process.arch
}-error.png`
)
});
}
await ctx.app.close();
await rm(ctx.userDataDir, {
force: true,
recursive: true,
maxRetries: 3,
retryDelay: 5000
}).catch(() => {
/*ignore */
});
await rm(ctx.outputDir, {
force: true,
recursive: true,
maxRetries: 3,
retryDelay: 5000
}).catch(() => {
/*ignore */
});
}

View File

@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"lib": ["ESNext"],
"moduleResolution": "Bundler",
"paths": {
"@nn/test": ["./electron-test/index.ts"]
}
}
}

View File

@@ -96,6 +96,7 @@ module.exports = {
"node_modules/sodium-native/package.json"
],
afterPack: "./scripts/removeLocales.js",
protocols: [{ name: "Notesnook", schemes: ["nn"] }],
mac: {
bundleVersion: "240",
minimumSystemVersion: "10.12.0",
@@ -181,6 +182,7 @@ module.exports = {
icon: "assets/icons/app.icns",
description: "Your private note taking space",
executableName: linuxExecutableName,
mimeTypes: ["x-scheme-handler/nn"],
desktop: {
desktopActions: {
"new-note": {

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"name": "@notesnook/desktop",
"productName": "Notesnook",
"description": "Your private note taking space",
"version": "3.3.23",
"version": "3.4.0-beta.1",
"appAppleId": "1544027013",
"private": true,
"main": "./dist/cjs/index.js",
@@ -21,6 +21,17 @@
"types": "./dist/types/index.d.ts",
"default": "./dist/esm/index.js"
}
},
"./testutils": "./__tests__/test-override.ts",
"./*": {
"require": {
"types": "./dist/types/*",
"default": "./dist/cjs/*"
},
"import": {
"types": "./dist/types/*",
"default": "./dist/esm/*"
}
}
},
"homepage": "https://notesnook.com/",
@@ -43,6 +54,7 @@
"zod": "3.24.3"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@streetwriters/kysely": "^0.27.4",
"@types/node": "22.15.3",
"@types/yargs": "^17.0.33",
@@ -50,9 +62,10 @@
"electron": "^37.0.0",
"electron-builder": "^26.8.1",
"esbuild": "0.21.5",
"fkill": "^10.0.3",
"node-abi": "^4.5.0",
"node-gyp-build": "^4.8.4",
"playwright": "1.48.2",
"playwright": "1.58.2",
"prebuildify": "^6.0.1",
"slugify": "1.6.6",
"tree-kill": "^1.2.2",
@@ -73,7 +86,7 @@
"bundle": "esbuild electron=./src/main.ts ./src/preload.ts --external:electron --external:fsevents --external:better-sqlite3-multiple-ciphers --external:sodium-native --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=false --define:RELEASE=true",
"bundle:mas": "esbuild electron=./src/main.ts ./src/preload.ts --minify --external:electron --external:fsevents --bundle --outdir=./build --platform=node --tsconfig=tsconfig.json --define:MAC_APP_STORE=true --define:RELEASE=true",
"postinstall": "patch-package",
"test": "vitest run"
"test": "playwright test --tsconfig tsconfig.json"
},
"author": {
"name": "Streetwriters (Private) Limited",

View File

@@ -0,0 +1,24 @@
diff --git a/node_modules/playwright-core/lib/protocol/validator.js b/node_modules/playwright-core/lib/protocol/validator.js
index b36c979..9e8ca15 100644
--- a/node_modules/playwright-core/lib/protocol/validator.js
+++ b/node_modules/playwright-core/lib/protocol/validator.js
@@ -2536,6 +2536,7 @@ import_validatorPrimitives.scheme.ElectronLaunchParams = (0, import_validatorPri
executablePath: (0, import_validatorPrimitives.tOptional)(import_validatorPrimitives.tString),
args: (0, import_validatorPrimitives.tOptional)((0, import_validatorPrimitives.tArray)(import_validatorPrimitives.tString)),
cwd: (0, import_validatorPrimitives.tOptional)(import_validatorPrimitives.tString),
+ baseURL: (0, import_validatorPrimitives.tOptional)(import_validatorPrimitives.tString),
env: (0, import_validatorPrimitives.tOptional)((0, import_validatorPrimitives.tArray)((0, import_validatorPrimitives.tType)("NameValue"))),
timeout: import_validatorPrimitives.tFloat,
acceptDownloads: (0, import_validatorPrimitives.tOptional)((0, import_validatorPrimitives.tEnum)(["accept", "deny", "internal-browser-default"])),
diff --git a/node_modules/playwright-core/types/types.d.ts b/node_modules/playwright-core/types/types.d.ts
index f6e6165..4efd4d4 100644
--- a/node_modules/playwright-core/types/types.d.ts
+++ b/node_modules/playwright-core/types/types.d.ts
@@ -19233,6 +19233,7 @@ export interface Electron {
* Additional arguments to pass to the application when launching. You typically pass the main script name here.
*/
args?: Array<string>;
+ baseURL?: string;
/**
* Toggles bypassing page's Content-Security-Policy. Defaults to `false`.

View File

@@ -0,0 +1,92 @@
/* eslint-disable header/header */
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {
Config,
PlaywrightTestOptions,
PlaywrightWorkerOptions
} from "@playwright/test";
import * as path from "path";
process.env.PWPAGE_IMPL = "electron";
process.env.TEST_DESKTOP = "true";
const outputDir = path.join(__dirname, "test-results");
const testDir = path.join(__dirname, "__tests__");
const config: Config<PlaywrightWorkerOptions & PlaywrightTestOptions> = {
testDir,
outputDir,
expect: {
timeout: 10000
},
use: {
acceptDownloads: true,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retry-with-video",
viewport: {
width: 1920,
height: 1080
}
},
timeout: 60000,
globalTimeout: 5400000,
workers: process.env.CI ? 1 : undefined,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 3 : 0,
reporter: process.env.CI
? [["dot"], ["json", { outputFile: path.join(outputDir, "report.json") }]]
: "line",
projects: [],
globalSetup: "./__tests__/electron-test/global-setup.ts"
};
const metadata = {
platform: process.platform,
headless: "headed",
browserName: "electron",
channel: undefined,
mode: "default",
video: false
};
config.projects?.push({
name: "notesnook-desktop",
// Share screenshots with chromium.
snapshotPathTemplate:
"{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-electron{ext}",
use: {
browserName: "chromium",
headless: false
},
testDir: "__tests__",
metadata
});
config.projects?.push({
name: "notesnook-web",
// Share screenshots with chromium.
snapshotPathTemplate:
"{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-electron{ext}",
use: {
browserName: "chromium",
headless: false
},
testDir: path.resolve(__dirname, "../web/__e2e__"),
metadata
});
export default config;

View File

@@ -33,6 +33,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const root = args.root || path.join(__dirname, "..");
const skipTscBuild = args.skipTscBuild || false;
const buildForTesting = args.test || false;
const webAppPath = path.resolve(path.join(__dirname, "..", "..", "web"));
@@ -51,7 +52,11 @@ console.log("removed build folder");
if (args.rebuild || !existsSync(path.join(webAppPath, "build"))) {
console.log("rebuilding...");
await exec(
"node scripts/execute.mjs @notesnook/web:build:desktop",
`node scripts/execute.mjs ${
buildForTesting
? "@notesnook/web:build:test:desktop"
: "@notesnook/web:build:desktop"
}`,
path.join(__dirname, "..", "..", "..")
);
}

View File

@@ -0,0 +1,89 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { initTRPC } from "@trpc/server";
import { createWriteStream, mkdirSync } from "node:fs";
import path from "node:path";
import { z } from "zod";
import { config } from "../utils/config";
import { resolvePath } from "../utils/resolve-path";
import { app } from "electron";
const t = initTRPC.create();
const activeStreams = new Map<string, NodeJS.WritableStream>();
function generateId() {
return Math.random().toString(36).slice(2);
}
export const backupsRouter = t.router({
open: t.procedure
.input(z.object({ filename: z.string() }))
.mutation(({ input }) => {
const { filename } = input;
if (filename.includes(path.sep) || filename.includes("\\"))
throw new Error("Invalid filename");
const resolvedBackupDir = resolvePath(config.backupDirectory);
const backupPath = path.resolve(resolvedBackupDir, filename);
if (!backupPath.startsWith(resolvedBackupDir))
throw new Error("Invalid filename");
mkdirSync(resolvedBackupDir, { recursive: true });
const stream = createWriteStream(backupPath, { encoding: "utf-8" });
const id = generateId();
activeStreams.set(id, stream);
return id;
}),
write: t.procedure
.input(z.object({ id: z.string(), chunk: z.string() }))
.mutation(({ input }) => {
const stream = activeStreams.get(input.id);
if (!stream) throw new Error("Stream not found");
return new Promise<void>((resolve, reject) => {
stream.write(Buffer.from(input.chunk, "base64"), (err) =>
err ? reject(err) : resolve()
);
});
}),
close: t.procedure
.input(z.object({ id: z.string() }))
.mutation(({ input }) => {
const stream = activeStreams.get(input.id);
if (!stream) throw new Error("Stream not found");
return new Promise<void>((resolve) => {
stream.end(() => {
activeStreams.delete(input.id);
resolve();
});
});
})
});
app.on("before-quit", () => {
try {
for (const stream of activeStreams.values()) {
stream.end();
}
} catch {
// ignore
}
});

View File

@@ -24,21 +24,43 @@ import TypedEventEmitter from "typed-emitter";
export type AppEvents = {
onCreateItem(name: "note" | "notebook" | "reminder"): void;
onOpenLink(url: string): void;
bridgeReady(): void;
};
const emitter = new EventEmitter();
const typedEmitter = emitter as TypedEventEmitter<AppEvents>;
let isBridgeReady = false;
const pendingEvents: { name: string; args: unknown[] }[] = [];
const _emitter = new EventEmitter();
const emitter = _emitter as TypedEventEmitter<AppEvents>;
const t = initTRPC.create();
export const bridgeRouter = t.router({
onCreateItem: createSubscription("onCreateItem")
onCreateItem: createSubscription("onCreateItem"),
onOpenLink: createSubscription("onOpenLink"),
ready: t.procedure.query(() => {
isBridgeReady = true;
if (pendingEvents.length > 0) {
console.log(
"Emitting pending events",
pendingEvents.map((e) => e.name)
);
pendingEvents.forEach((event) => {
emitter.emit(event.name as any, ...(event.args as any[]));
});
pendingEvents.length = 0;
}
return true;
})
});
export const bridge: AppEvents = new Proxy({} as AppEvents, {
get(_t, name) {
if (typeof name === "symbol") return;
return (...args: unknown[]) => {
emitter.emit(name, ...args);
if (!isBridgeReady) {
pendingEvents.push({ name, args });
return;
}
_emitter.emit(name, ...args);
};
}
});
@@ -49,9 +71,9 @@ function createSubscription<TName extends keyof AppEvents>(eventName: TName) {
const listener: AppEvents[TName] = (...args: any[]) => {
emit.next(args[0]);
};
typedEmitter.addListener(eventName, listener);
emitter.addListener(eventName, listener);
return () => {
typedEmitter.removeListener(eventName, listener);
emitter.removeListener(eventName, listener);
};
});
});

View File

@@ -25,6 +25,8 @@ import { updaterRouter } from "./updater";
import { bridgeRouter } from "./bridge";
import { safeStorageRouter } from "./safe-storage";
import { windowRouter } from "./window";
import { sqliteRouter } from "./sqlite-kysely";
import { backupsRouter } from "./backups";
const t = initTRPC.create();
@@ -35,10 +37,13 @@ export const router = t.router({
updater: updaterRouter,
bridge: bridgeRouter,
safeStorage: safeStorageRouter,
window: windowRouter
window: windowRouter,
sqlite: sqliteRouter,
backups: backupsRouter
});
export const api = router.createCaller({});
const createCaller = t.createCallerFactory(router);
export const api = createCaller({});
// Export type router type signature,
// NOT the router itself.

View File

@@ -24,7 +24,6 @@ import {
dialog,
Menu,
MenuItem,
nativeImage,
nativeTheme,
Notification,
shell
@@ -33,17 +32,16 @@ import { AutoLaunch } from "../utils/autolaunch";
import { config, DesktopIntegration } from "../utils/config";
import { bringToFront } from "../utils/bring-to-front";
import { getTheme, setTheme, Theme } from "../utils/theme";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { dirname } from "path";
import { existsSync } from "fs";
import { resolvePath } from "../utils/resolve-path";
import { observable } from "@trpc/server/observable";
import { AssetManager } from "../utils/asset-manager";
import { isFlatpak, isPortable, isSnap } from "../utils";
import { setupDesktopIntegration } from "../utils/desktop-integration";
import { rm } from "fs/promises";
import { disableCustomDns, enableCustomDns } from "../utils/custom-dns";
import type { MenuItem as NNMenuItem } from "@notesnook/ui";
import { platform } from "os";
import { strings } from "@notesnook/intl";
const t = initTRPC.create();
@@ -62,6 +60,7 @@ export const osIntegrationRouter = t.router({
isFlatpak: t.procedure.query(() => isFlatpak()),
isSnap: t.procedure.query(() => isSnap()),
isPortable: t.procedure.query(() => isPortable()),
backupDirectory: t.procedure.query(() => config.backupDirectory),
zoomFactor: t.procedure.query(() => config.zoomFactor),
setZoomFactor: t.procedure.input(z.number()).mutation(({ input: factor }) => {
@@ -118,53 +117,19 @@ export const osIntegrationRouter = t.router({
setupDesktopIntegration(settings);
}),
selectDirectory: t.procedure
.input(
z.object({
title: z.string().optional(),
buttonLabel: z.string().optional(),
defaultPath: z.string().optional()
})
)
.query(async ({ input }) => {
if (!globalThis.window) return undefined;
selectBackupDirectory: t.procedure.input(z.undefined()).query(async () => {
if (!globalThis.window) return undefined;
const { title, buttonLabel, defaultPath } = input;
const result = await dialog.showOpenDialog(globalThis.window, {
title: strings.selectBackupDir(),
buttonLabel: strings.select(),
properties: ["openDirectory"],
defaultPath: config.backupDirectory && resolvePath(config.backupDirectory)
});
if (result.canceled) return undefined;
const result = await dialog.showOpenDialog(globalThis.window, {
title,
buttonLabel,
properties: ["openDirectory"],
defaultPath: defaultPath && resolvePath(defaultPath)
});
if (result.canceled) return undefined;
return result.filePaths[0];
}),
saveFile: t.procedure
.input(z.object({ data: z.string(), filePath: z.string() }))
.query(({ input }) => {
const { data, filePath } = input;
if (!data || !filePath) return;
const resolvedPath = resolvePath(filePath);
mkdirSync(dirname(resolvedPath), { recursive: true });
writeFileSync(resolvedPath, data);
}),
resolvePath: t.procedure
.input(z.object({ filePath: z.string() }))
.query(({ input }) => {
const { filePath } = input;
return resolvePath(filePath);
}),
deleteFile: t.procedure.input(z.string()).query(async ({ input }) => {
await rm(input);
config.backupDirectory = result.filePaths[0];
}),
restart: t.procedure.query(() => {
app.relaunch();
app.exit();
@@ -199,10 +164,12 @@ export const osIntegrationRouter = t.router({
const { type, link } = input;
if (type !== "path") return;
const path = decodeURIComponent(new URL(link).pathname);
const resolvedPath = resolvePath(
// remove leading slash from path on windows
platform() === "win32" ? link.slice(1) : link
platform() === "win32" ? path.slice(1) : path
);
if (!existsSync(resolvedPath)) {
if (globalThis.window) {
await dialog.showMessageBox(globalThis.window, {
@@ -214,7 +181,15 @@ export const osIntegrationRouter = t.router({
return;
}
await shell.openPath(resolvedPath);
const result = await dialog.showMessageBox(globalThis.window!, {
message: strings.openingLocalFileDesc(resolvedPath),
title: strings.openingLocalFile(),
buttons: [strings.cancel(), strings.open()],
defaultId: 1,
cancelId: 0,
type: "question"
});
result.response === 1 && (await shell.openPath(resolvedPath));
}),
bringToFront: t.procedure.query(() => bringToFront()),
changeTheme: t.procedure

View File

@@ -19,6 +19,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import type { Database, Statement } from "better-sqlite3-multiple-ciphers";
import type { QueryResult } from "@streetwriters/kysely";
import { app } from "electron";
import path from "node:path";
import { initTRPC } from "@trpc/server";
type SQLiteCompatibleType =
| number
@@ -28,25 +31,32 @@ type SQLiteCompatibleType =
| bigint
| null;
export class SQLite {
class SQLite {
sqlite?: Database;
initialized = false;
preparedStatements: Map<string, Statement<unknown[]>> = new Map();
retryCounter: Record<string, number> = {};
extensionsLoaded = false;
private filePath?: string;
constructor() {
console.log("new sqlite worker");
}
async open(filePath: string) {
async open(filename: string) {
if (this.sqlite) {
console.error("Database is already initialized");
return;
}
this.filePath =
filename === ":memory:"
? filename
: path.join(app.getPath("userData"), filename) + ".sql";
if (!isPathAllowed(this.filePath))
throw new Error("Database path is not allowed: " + this.filePath);
this.sqlite = require("better-sqlite3-multiple-ciphers")(
filePath
this.filePath
).unsafeMode(true);
}
@@ -155,9 +165,11 @@ export class SQLite {
this.sqlite = undefined;
}
async delete(filePath: string) {
async delete() {
if (!this.filePath) return;
await this.close();
await require("fs/promises").rm(filePath, {
await require("node:fs/promises").rm(this.filePath, {
force: true,
maxRetries: 5,
retryDelay: 500
@@ -221,3 +233,67 @@ function rewriteError(e: Error, message: string) {
error.cause = e.cause;
return error;
}
function isPathAllowed(databasePath: string) {
if (databasePath === ":memory:") return true;
const base = app.getPath("userData");
const resolved = path.resolve(databasePath);
return resolved.startsWith(base + path.sep);
}
const t = initTRPC.create();
const databases: Record<string, SQLite> = {};
export const sqliteRouter = t.router({
open: t.procedure
.input((v) => v)
.mutation(async ({ input }) => {
const { filePath } = input as { filePath: string };
if (databases[filePath]) return filePath;
const sqlite = new SQLite();
await sqlite.open(filePath);
databases[filePath] = sqlite;
return filePath;
}),
run: t.procedure
.input((v) => v)
.mutation(async ({ input }) => {
const { id, sql, parameters } = input as {
id: string;
sql: string;
parameters?: SQLiteCompatibleType[];
};
const sqlite = databases[id];
if (!sqlite) throw new Error("Database not found for id: " + id);
return await sqlite.run(sql, parameters);
}),
close: t.procedure
.input((v) => v)
.mutation(async ({ input }) => {
const { id } = input as { id: string };
const sqlite = databases[id];
if (!sqlite) throw new Error("Database not found for id: " + id);
await sqlite.close();
delete databases[id];
}),
delete: t.procedure
.input((v) => v)
.mutation(async ({ input }) => {
const { id } = input as { id: string };
const sqlite = databases[id];
if (!sqlite) throw new Error("Database not found for id: " + id);
await sqlite.delete();
delete databases[id];
})
});
app.on("before-quit", async () => {
for (const db of Object.values(databases)) {
try {
await db.close();
} catch (e) {
console.error("Error closing database:", e);
}
}
});

View File

@@ -21,4 +21,3 @@ export { PATHS } from "./constants";
export type { AppRouter } from "./api";
export { type UpdateInfo } from "builder-util-runtime";
export { type DesktopIntegration } from "./utils/config";
export { SQLite } from "./api/sqlite-kysely.js";

View File

@@ -39,6 +39,7 @@ import { setupDesktopIntegration } from "./utils/desktop-integration";
import { disableCustomDns, enableCustomDns } from "./utils/custom-dns";
import { Messages, setI18nGlobal } from "@notesnook/intl";
import { i18n } from "@lingui/core";
import { PATHS } from "./constants";
const locale =
process.env.NODE_ENV === "development"
@@ -55,6 +56,10 @@ setI18nGlobal(i18n);
const appHostnames = isDevelopment()
? ["localhost", "127.0.0.1"]
: ["app.notesnook.com"];
// Pending nn:// link to open once the window is ready (used on Windows/Linux
// when the app is launched via the nn:// protocol for the first time).
let pendingNNLink: string | undefined = findNNLink(process.argv);
// only run a single instance
if (!MAC_APP_STORE && !app.requestSingleInstanceLock()) {
console.log("Another instance is already running!");
@@ -125,9 +130,6 @@ async function createWindow() {
webPreferences: {
zoomFactor: config.zoomFactor,
nodeIntegration: true,
contextIsolation: false,
nodeIntegrationInWorker: true,
spellcheck: config.isSpellCheckerEnabled,
preload: __dirname + "/preload.js"
}
@@ -193,6 +195,11 @@ async function createWindow() {
setupTray();
setupJumplist();
});
if (pendingNNLink) {
bridge.onOpenLink(pendingNNLink);
pendingNNLink = undefined;
}
}
app.once("ready", async () => {
@@ -212,9 +219,12 @@ app.once("ready", async () => {
if (config.customDns) enableCustomDns();
else disableCustomDns();
if (!MAC_APP_STORE) app.setAsDefaultProtocolClient("nn");
if (!isDevelopment()) registerProtocol();
await createWindow();
configureAutoUpdater();
await migrateBackupDirectory();
await configureAutoUpdater();
});
app.once("window-all-closed", () => {
@@ -225,6 +235,12 @@ app.once("window-all-closed", () => {
app.on("second-instance", async (_ev, argv) => {
if (!globalThis.window) return;
const nnLink = findNNLink(argv);
if (nnLink) {
bridge.onOpenLink(nnLink);
bringToFront();
return;
}
const cliOptions = await parseArguments(argv);
if (cliOptions.note) bridge.onCreateItem("note");
if (cliOptions.notebook) bridge.onCreateItem("notebook");
@@ -232,12 +248,29 @@ app.on("second-instance", async (_ev, argv) => {
bringToFront();
});
// macOS opens URLs via this event. The app may or may not be fully loaded yet.
app.on("open-url", (event, url) => {
event.preventDefault();
if (!url.startsWith("nn://")) return;
if (globalThis.window) {
bridge.onOpenLink(url);
bringToFront();
} else {
// Window not ready yet — store for when createWindow finishes loading.
pendingNNLink = url;
}
});
app.on("activate", () => {
if (globalThis.window === null) {
createWindow();
}
});
function findNNLink(argv: string[]): string | undefined {
return argv.find((arg) => arg.startsWith("nn://"));
}
function createURL(options: CLIOptions, path = "/") {
const url = new URL(isDevelopment() ? "http://localhost:3000" : PROTOCOL_URL);
@@ -248,7 +281,31 @@ function createURL(options: CLIOptions, path = "/") {
else if (typeof options.note === "string")
url.hash = `/notes/${options.note}/edit`;
else if (typeof options.notebook === "string")
url.hash = `/notebooks/${options.notebook}`;
url.pathname = `/notebooks/${options.notebook}`;
return url;
}
async function migrateBackupDirectory() {
if (!globalThis.window) return;
try {
if (config.backupDirectory !== PATHS.backupsDirectory) return;
const oldPath = await globalThis.window?.webContents.executeJavaScript(
`localStorage.getItem("backupStorageLocation")`
);
if (!oldPath || oldPath === PATHS.backupsDirectory) return;
config.backupDirectory = oldPath;
} catch (e) {
console.error("Failed to migrate backup directory", e);
const pressedButton = dialog.showMessageBoxSync(globalThis.window, {
message:
"Failed to migrate backup directory. It has been reset to default.",
title: "Backup Directory Migration Failed",
type: "error",
buttons: ["Set backup directory", "Ignore"]
});
if (pressedButton === 0) {
await api.integration.selectBackupDirectory();
}
}
}

View File

@@ -19,25 +19,21 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/* eslint-disable no-var */
import { ELECTRON_TRPC_CHANNEL } from "electron-trpc/main";
// import type { NNCrypto } from "@notesnook/crypto";
import { ipcRenderer } from "electron";
import { platform } from "os";
import { ipcRenderer, contextBridge } from "electron";
declare global {
var os: () => "mas" | ReturnType<typeof platform>;
var os: () => "mas" | typeof process.platform;
var electronTRPC: any;
// var NativeNNCrypto: (new () => NNCrypto) | undefined;
}
process.once("loaded", async () => {
const electronTRPC = {
sendMessage: (operation: any) =>
ipcRenderer.send(ELECTRON_TRPC_CHANNEL, operation),
onMessage: (callback: any) =>
ipcRenderer.on(ELECTRON_TRPC_CHANNEL, (_event, args) => callback(args))
};
globalThis.electronTRPC = electronTRPC;
});
const electronTRPC = {
sendMessage: (operation: any) =>
ipcRenderer.send(ELECTRON_TRPC_CHANNEL, operation),
onMessage: (callback: any) =>
ipcRenderer.on(ELECTRON_TRPC_CHANNEL, (_event, args) => callback(args))
};
// globalThis.NativeNNCrypto = require("@notesnook/crypto").NNCrypto;
globalThis.os = () => (MAC_APP_STORE ? "mas" : platform());
const os = () => (MAC_APP_STORE ? "mas" : process.platform);
contextBridge.exposeInMainWorld("electronTRPC", electronTRPC);
contextBridge.exposeInMainWorld("os", os);

View File

@@ -21,6 +21,7 @@ import { nativeTheme } from "electron";
import { JSONStorage } from "./json-storage";
import { z } from "zod";
import { autoUpdater } from "electron-updater";
import { PATHS } from "../constants";
export const DesktopIntegration = z.object({
autoStart: z.boolean().optional(),
@@ -53,7 +54,8 @@ export const config = {
backgroundColor: nativeTheme.themeSource === "dark" ? "#0f0f0f" : "#ffffff",
windowControlsIconColor:
nativeTheme.themeSource === "dark" ? "#ffffff" : "#000000"
nativeTheme.themeSource === "dark" ? "#ffffff" : "#000000",
backupDirectory: PATHS.backupsDirectory
};
type ConfigKey = keyof typeof config;

View File

@@ -1,9 +1,11 @@
{
"extends": "../../tsconfig",
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist/",
"lib": ["ESNext"],
"moduleResolution": "Bundler"
},
"include": ["src", "global.d.ts"]
"files": ["global.d.ts"],
"include": ["src"],
"exclude": ["__tests__"]
}

View File

@@ -30,7 +30,7 @@ module.exports = {
testBinaryPath:
"android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk",
build:
"cd android ; ENVFILE=.env.test ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..",
"cd android ; ENVFILE=.env.test ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug -PreactNativeArchitectures=arm64-v8a && cd ..",
reversePorts: [8081]
},
"android.release": {

View File

@@ -1,4 +1,5 @@
.yarnrc.yml
tsconfig.tsbuildinfo
artifacts/
# OSX
#

View File

@@ -0,0 +1,9 @@
{
"iconSets": [
{
"inputDir": "../../packages/icons/svgs",
"fontFamily": "notesnook-icons",
"outputDir": "./fonts"
}
]
}

View File

@@ -140,7 +140,7 @@ android {
if (project.hasProperty("prBuildNumber")) {
versionCode Integer.parseInt(prBuildNumber())
} else {
versionCode 3107
versionCode 3108
}
versionName getNpmVersion()
testBuildType System.getProperty('testBuildType', 'debug')

View File

@@ -155,6 +155,15 @@
<data android:scheme="notesnook" />
</intent-filter>
<intent-filter android:label="Notesnook">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nn" />
</intent-filter>
</activity>
<activity
android:name="com.facebook.react.devsupport.DevSettingsActivity"

View File

@@ -33,7 +33,7 @@ public class NotePreviewWidget extends AppWidgetProvider {
intent.putExtra(OpenNoteId, note.getId());
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra(RCTNNativeModule.IntentType, "OpenNote");
intent.setData(Uri.parse("https://app.notesnook.com/open_note?id=" + note.getId()));
intent.setData(Uri.parse("nn://note/" + note.getId()));
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
views.setOnClickPendingIntent(R.id.open_note, pendingIntent);

View File

@@ -244,7 +244,7 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
return;
}
String uri = "https://app.notesnook.com/open_" + type + "?id=" + id;
String uri = "nn://" + type + "/" + id;
Intent intent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(uri));
intent.setPackage(mContext.getPackageName());

View File

@@ -1,3 +1,7 @@
- Bug fixes and improvements
- Inbox API is now in beta. Learn more about it at https://help.notesnook.com/inbox-api/getting-started
- Added close all tabs button in editor
- Deeplinking support for nn:// urls
- Set default image compression mode in Settings -> Behavior
- Minor bug fixes and performance improvements
Thank you for using Notesnook!
Thank you for using Notesnook!

View File

@@ -35,7 +35,7 @@ import { Toast } from "./components/toast";
import { useAppEvents } from "./hooks/use-app-events";
import { NotePreviewConfigure } from "./screens/note-preview-configure";
import { RootNavigation } from "./navigation/navigation-stack";
import { themeTrpcClient } from "./screens/settings/theme-selector";
import { themeTrpcClient } from "./screens/settings/components/theme-selector";
import Notifications from "./services/notifications";
import SettingsService from "./services/settings";
import { TipManager } from "./services/tip-manager";
@@ -44,6 +44,7 @@ import { useUserStore } from "./stores/use-user-store";
import RNBootSplash from "react-native-bootsplash";
import AppLocked from "./components/app-lock";
import { useSettingStore } from "./stores/use-setting-store";
import { THEME_DARK, THEME_LIGHT } from "./common/design/theme";
I18nManager.allowRTL(false);
I18nManager.forceRTL(false);
I18nManager.swapLeftAndRightInRTL(false);
@@ -120,30 +121,30 @@ export const withTheme = (
]);
useEffect(() => {
setTimeout(() => {
const currentTheme = colorScheme === "dark" ? darkTheme : lightTheme;
if (!currentTheme) return;
themeTrpcClient.updateTheme
.query({
version: currentTheme.version,
compatibilityVersion: THEME_COMPATIBILITY_VERSION,
id: currentTheme.id
})
.then((theme) => {
if (theme) {
theme.colorScheme === "dark"
? useThemeStore.setState({
darkTheme: theme
})
: useThemeStore.setState({
lightTheme: theme
});
}
})
.catch(() => {
/* empty */
});
}, 1000);
// setTimeout(() => {
// const currentTheme = colorScheme === "dark" ? darkTheme : lightTheme;
// if (!currentTheme) return;
// themeTrpcClient.updateTheme
// .query({
// version: currentTheme.version,
// compatibilityVersion: THEME_COMPATIBILITY_VERSION,
// id: currentTheme.id
// })
// .then((theme) => {
// if (theme) {
// theme.colorScheme === "dark"
// ? useThemeStore.setState({
// darkTheme: theme
// })
// : useThemeStore.setState({
// lightTheme: theme
// });
// }
// })
// .catch(() => {
// /* empty */
// });
// }, 1000);
const listener = Appearance.addChangeListener(({ colorScheme }) => {
if (colorScheme && SettingsService.getProperty("useSystemTheme")) {
@@ -159,11 +160,11 @@ export const withTheme = (
}, []);
useEffect(() => {
const nextTheme = colorScheme === "dark" ? darkTheme : lightTheme;
const nextTheme = colorScheme === "dark" ? THEME_DARK : THEME_LIGHT;
if (JSON.stringify(nextTheme) !== JSON.stringify(currTheme)) {
useThemeEngineStore
.getState()
.setTheme(colorScheme === "dark" ? darkTheme : lightTheme);
.setTheme(colorScheme === "dark" ? THEME_DARK : THEME_LIGHT);
currTheme = nextTheme;
}
}, [colorScheme, darkTheme, lightTheme]);

File diff suppressed because one or more lines are too long

View File

@@ -31,6 +31,8 @@ import {
generateCryptoKeyFallback
} from "./encryption";
import { MMKV } from "./mmkv";
import OpenPGP from "react-native-fast-openpgp";
import { strings } from "@notesnook/intl";
export class KV {
storage: MMKVInstance;
@@ -136,11 +138,41 @@ export const Storage: IStorage = {
clear(): Promise<void> {
return DefaultStorage.clear();
},
generateCryptoKeyPair() {
throw new Error("Not implemented");
async generatePGPKeyPair() {
const keys = await OpenPGP.generate({
name: "NN",
email: "NN@NN.NN"
});
return { publicKey: keys.publicKey, privateKey: keys.privateKey };
},
decryptAsymmetric() {
throw new Error("Not implemented");
async validatePGPKeyPair(keys): Promise<{
isValid: boolean;
message: string;
}> {
try {
const dummyData = JSON.stringify({
favorite: true,
title: "Hello world"
});
const encrypted = await OpenPGP.encrypt(dummyData, keys.publicKey);
const decrypted = await OpenPGP.decrypt(encrypted, keys.privateKey, "");
const isValid = decrypted === dummyData;
return {
isValid,
message: isValid ? "" : strings.invalidPgpKeyPair()
};
} catch (e) {
console.error("PGP key pair validation error:", e);
return {
isValid: false,
message: strings.invalidPgpKeyPair()
};
}
},
async decryptPGPMessage(privateKeyArmored, encryptedMessage) {
return await OpenPGP.decrypt(encryptedMessage, privateKeyArmored, "");
},
getAllKeys(): Promise<string[]> {
return DefaultStorage.getAllKeys();

View File

@@ -0,0 +1,56 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export const FontSizes = {
XXS: 10,
XS: 12,
SM: 14,
MD: 16,
LG: 18,
XL: 20,
XXL: 32
};
export const FontFamily = {
REGULAR: "Inter-Regular", // 400
MEDIUM: "Inter-Medium", // 500
SEMI_BOLD: "Inter-SemiBold", // 600
BOLD: "Inter-Bold" // 700
};
const LineHeightMultipliers = {
"100%": 1,
"110%": 1.1,
"120%": 1.2,
"130%": 1.3,
"140%": 1.4,
"150%": 1.5
};
export type LineHeightVariants =
| "100%"
| "110%"
| "120%"
| "130%"
| "140%"
| "150%";
export const getLineHeight = (
fontSize: keyof typeof FontSizes,
type: LineHeightVariants
) => {
return FontSizes[fontSize] * LineHeightMultipliers[type];
};

View File

@@ -0,0 +1,39 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
export const Spacing = {
LEVEL_0: 4,
LEVEL_1: 8,
LEVEL_2: 12,
LEVEL_3: 16,
LEVEL_4: 20,
LEVEL_5: 24,
LEVEL_6: 28,
LEVEL_7: 32,
LEVEL_8: 40
};
export const Radius = {
XXS: 4,
XS: 8,
S: 12,
MD: 16,
LG: 20,
LG_2: 32,
XXL: 40
};

View File

@@ -0,0 +1,283 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { ThemeDefinition } from "@notesnook/theme";
export const THEME_LIGHT: any = {
$schema:
"https://raw.githubusercontent.com/streetwriters/notesnook-themes/main/schemas/v1.schema.json",
name: "Notesnook Light",
id: "default-light",
version: 2.1,
license: "GPL-3.0-or-later",
homepage: "https://notesnook.com",
description: "The default light theme for Notesnook app.",
colorScheme: "light",
codeBlockCSS: ``,
compatibilityVersion: 1,
authors: [
{
name: "Streetwriters",
email: "support@streetwriters.co",
url: "https://streetwriters.co"
}
],
scopes: {
base: {
primary: {
accent: "#008836",
accentForeground: "#ffffff",
paragraph: "#666666",
background: "#ffffff",
border: "#E2E2E2",
heading: "#181818",
icon: "#181818",
separator: "#F0F0F0",
placeholder: "#858585",
hover: "#eee",
backdrop: "#0000001a",
buttonForeground: "#FFFFFF"
},
secondary: {
accent: "#0D9E46",
accentForeground: "#ffffff",
paragraph: "#858585",
background: "#F7F7F7",
border: "#EFEFEF",
heading: "#4B4B4B",
icon: "#666666",
separator: "#E8E8E8",
placeholder: "#999999",
hover: "#dbdbdb",
backdrop: "#0000001a",
buttonForeground: "#181818"
},
tertiary: {
accent: "#008836",
accentForeground: "#ffffff",
paragraph: "#777",
background: "#ECECEC",
border: "#E8E8E8",
heading: "#666",
icon: "#777",
separator: "#E8E8E8",
placeholder: "#a9a9a9",
hover: "#dbdbdb",
backdrop: "#0000001a"
},
disabled: {
accent: "#008836",
accentForeground: "#ffffff",
paragraph: "#A6A6A6",
background: "#ffffff",
border: "#E8E8E8",
heading: "#212121",
icon: "#DADADA",
separator: "#E8E8E8",
placeholder: "#a9a9a9",
hover: "#00000010",
backdrop: "#0000001a"
},
selected: {
accent: "#008836",
accentForeground: "#ffffff",
paragraph: "#212121",
background: "#00883610",
border: "#008836",
heading: "#212121",
icon: "#181818",
separator: "#E8E8E8",
placeholder: "#a9a9a9",
hover: "#eee",
backdrop: "#0000001a"
},
error: {
accent: "#f54b42",
accentForeground: "#ffffff",
paragraph: "#FF242E",
background: "#FFEDEE",
border: "#F7999D",
heading: "#f54b42",
icon: "#FB2C36",
separator: "#E8E8E8",
placeholder: "#a9a9a9",
hover: "#00000010",
backdrop: "#0000001a"
},
success: {
accent: "#4F8A10",
accentForeground: "#ffffff",
paragraph: "#4F8A10",
background: "#85ce68",
border: "#E8E8E8",
heading: "#4F8A10",
icon: "#4F8A10",
separator: "#E8E8E8",
placeholder: "#a9a9a9",
hover: "#00000010",
backdrop: "#0000001a"
}
},
statusBar: {
primary: { paragraph: "#5E5E5E", background: "#f7f7f7" }
},
navigationMenu: {
primary: {
background: "#f7f7f7",
paragraph: "#666",
icon: "#666",
hover: "#eee"
},
secondary: { background: "#EEEEEE" },
selected: {
background: "#00883610",
hover: "#eee"
}
},
contextMenu: { primary: { background: "#f7f7f7" } },
editor: { selected: { background: "#e1e1e1" } },
sheet: { selected: { paragraph: "#008836" } },
list: {
secondary: {
heading: "#A6A6A6"
}
}
}
};
export const THEME_DARK: any = {
name: "Notesnook Dark",
id: "default-dark",
version: 2.1,
license: "GPL-3.0-or-later",
homepage: "https://notesnook.com",
description: "The default dark theme for the Notesnook app",
colorScheme: "dark",
codeBlockCSS: ``,
compatibilityVersion: 1,
authors: [
{
name: "Streetwriters",
email: "support@streetwriters.co",
url: "https://streetwriters.co"
}
],
scopes: {
base: {
primary: {
accent: "#008836",
accentForeground: "#ffffff",
paragraph: "#F2F2F2",
background: "#181818",
border: "#2A2A2A",
heading: "#FFFFFF",
icon: "#FFFFFF",
separator: "#383838",
placeholder: "#404040",
hover: "#2b2b2b",
backdrop: "#35353580",
buttonForeground: "#FFFFFF"
},
secondary: {
accent: "#0D9E46",
accentForeground: "#ffffff",
paragraph: "#A1A1A1",
background: "#252525",
border: "#0A5729",
heading: "#C0BFBF",
icon: "#808080",
separator: "#383838",
placeholder: "#404040",
hover: "#ffffff10",
backdrop: "#35353580",
buttonForeground: "#BFBFBF"
},
tertiary: {
accent: "#0D9E46",
accentForeground: "#ffffff",
paragraph: "#818589",
background: "#2C2C2C",
border: "#2b2b2b",
heading: "#808080",
icon: "#808080",
separator: "#383838",
placeholder: "#404040",
hover: "#ffffff10",
backdrop: "#35353580"
},
disabled: {
accent: "#008836",
accentForeground: "#ffffff",
paragraph: "#505050",
background: "#ffffff",
border: "#383838",
heading: "#212121",
icon: "#FFFFFF",
separator: "#383838",
placeholder: "#404040",
hover: "#ffffff10",
backdrop: "#35353580"
},
selected: {
accent: "#008836",
accentForeground: "#ffffff",
paragraph: "#FBFBFB",
background: "#494949",
border: "#008836",
heading: "#e3e3e3",
icon: "#008836",
separator: "#383838",
placeholder: "#404040",
hover: "#ffffff10",
backdrop: "#35353580"
},
error: {
accent: "#f54b42",
accentForeground: "#ffffff",
paragraph: "#f54b42",
background: "#250b0a",
border: "#F7999D",
heading: "#f54b42",
icon: "#f54b42",
separator: "#383838",
placeholder: "#404040",
hover: "#ffffff10",
backdrop: "#35353580"
},
success: {
accent: "#4F8A10",
accentForeground: "#ffffff",
paragraph: "#4F8A10",
background: "#132204",
border: "#383838",
heading: "#4F8A10",
icon: "#4F8A10",
separator: "#383838",
placeholder: "#404040",
hover: "#ffffff10",
backdrop: "#35353580"
}
},
list: { selected: { background: "#202020" } },
contextMenu: { primary: { background: "#202020" } },
sheet: { selected: { paragraph: "#008836" } }
},
$schema:
"https://raw.githubusercontent.com/streetwriters/notesnook-themes/main/schemas/v1.schema.json"
};

View File

@@ -55,6 +55,7 @@ import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view
import { strings } from "@notesnook/intl";
import { editorController } from "../../screens/editor/tiptap/utils";
import { useTabStore } from "../../screens/editor/tiptap/use-tab-store";
import { PASSWORD_PLACEHOLDER } from "../../utils/constants";
const getUser = () => {
const user = MMKV.getString("user");
@@ -96,9 +97,6 @@ const AppLocked = () => {
const lastAppState = useRef<AppStateStatus>(appState);
const biometricUnlockAwaitingUserInput = useRef(false);
const { height } = useWindowDimensions();
const keyboardType = useSettingStore(
(state) => state.settings.applockKeyboardType
);
const appLockHasPasswordSecurity = useSettingStore(
(state) => state.settings.appLockHasPasswordSecurity
);
@@ -157,8 +155,7 @@ const AppLocked = () => {
if (!appLockHasPasswordSecurity) {
await setAppLockVerificationCipher(password.current);
SettingsService.set({
appLockHasPasswordSecurity: true,
applockKeyboardType: "default"
appLockHasPasswordSecurity: true
});
DatabaseLogger.info("App lock migrated to password security");
}
@@ -167,7 +164,7 @@ const AppLocked = () => {
password.current = undefined;
} else {
ToastManager.show({
heading: strings.invalid(keyboardType),
heading: strings.invalid("password"),
type: "error",
context: "local"
});
@@ -290,9 +287,7 @@ const AppLocked = () => {
<Input
fwdRef={passwordInputRef}
secureTextEntry
keyboardType={
appLockHasPasswordSecurity ? keyboardType : "default"
}
keyboardType={"default"}
onLayout={async () => {
if (
!biometricsAuthEnabled ||
@@ -303,13 +298,7 @@ const AppLocked = () => {
}, 32);
}
}}
placeholder={
appLockHasPasswordSecurity
? keyboardType === "numeric"
? strings.enterApplockPassword()
: strings.enterApplockPin()
: strings.enterAccountPassword()
}
placeholder={PASSWORD_PLACEHOLDER}
onChangeText={(v) => (password.current = v)}
onSubmit={() => {
onSubmit();

View File

@@ -34,7 +34,7 @@ import create from "zustand";
import { db } from "../../common/database";
import filesystem from "../../common/filesystem";
import { downloadAttachments } from "../../common/filesystem/download-attachment";
import { AttachmentGroupProgress } from "../../screens/settings/attachment-group-progress";
import { AttachmentGroupProgress } from "../../screens/settings/components/attachment-group-progress";
import { presentSheet, ToastManager } from "../../services/event-manager";
import { useAttachmentStore } from "../../stores/use-attachment-store";
import { AppFontSize } from "../../utils/size";

View File

@@ -23,10 +23,9 @@ import React, { useRef, useState } from "react";
import { View } from "react-native";
import { db } from "../../common/database";
import BackupService from "../../services/backup";
import { eSendEvent, ToastManager } from "../../services/event-manager";
import { ToastManager } from "../../services/event-manager";
import Navigation from "../../services/navigation";
import { useUserStore } from "../../stores/use-user-store";
import { eOpenRecoveryKeyDialog } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Dialog } from "../dialog";
@@ -36,6 +35,8 @@ import FormInput, { createFormRef, validators } from "../ui/input/form-input";
import { Notice } from "../ui/notice";
import Paragraph from "../ui/typography/paragraph";
import { TextInput } from "react-native-gesture-handler";
import { Spacing } from "../../common/design/spacing";
import RecoveryKeySheet from "../sheets/recovery-key";
export const ChangePassword = () => {
const { colors } = useThemeColors();
@@ -92,7 +93,7 @@ export const ChangePassword = () => {
});
setLoading(false);
Navigation.goBack();
eSendEvent(eOpenRecoveryKeyDialog);
RecoveryKeySheet.present();
} catch (e) {
const message = (e as Error).message;
setLoading(false);
@@ -109,12 +110,15 @@ export const ChangePassword = () => {
<View
style={{
width: "100%",
padding: DefaultAppStyles.GAP
paddingTop: Spacing.LEVEL_0,
paddingHorizontal: Spacing.LEVEL_3,
gap: Spacing.LEVEL_2
}}
>
<Dialog context="change-password-dialog" />
<FormInput
name="oldPassword"
label={strings.oldPassword()}
formRef={formRef}
fwdRef={oldPasswordInputRef}
loading={loading}
@@ -125,7 +129,7 @@ export const ChangePassword = () => {
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.currentPassword()}
placeholder={"•••••••••"}
onSubmitEditing={() => {
passwordInputRef.current?.focus();
}}
@@ -133,6 +137,7 @@ export const ChangePassword = () => {
<FormInput
name="password"
label={strings.newPassword()}
formRef={formRef}
fwdRef={passwordInputRef}
loading={loading}
@@ -143,7 +148,28 @@ export const ChangePassword = () => {
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.newPassword()}
placeholder={"•••••••••"}
onSubmitEditing={() => {
changePassword();
}}
/>
<FormInput
name="confirmPassword"
label={strings.confirmPassword()}
formRef={formRef}
fwdRef={passwordInputRef}
loading={loading}
validators={[
validators.matchField("password", strings.confirmPasswordRequired())
]}
returnKeyLabel={strings.next()}
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={"•••••••••"}
onSubmitEditing={() => {
changePassword();
}}
@@ -168,15 +194,14 @@ export const ChangePassword = () => {
</Paragraph>
) : null}
<Notice text={strings.changePasswordNotice()} type="alert" />
<View style={{ height: 10 }} />
<Notice text={strings.changePasswordNotice2()} type="alert" />
<Notice
text={strings.changePasswordNotice()}
type="information"
size="small"
/>
<Button
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
width: "100%"
}}
loading={loading}
@@ -184,6 +209,23 @@ export const ChangePassword = () => {
type="accent"
title={loading ? null : strings.changePasswordConfirm()}
/>
<View
style={{
flexDirection: "row",
gap: Spacing.LEVEL_1,
alignSelf: "center",
marginTop: Spacing.LEVEL_0
}}
>
<AppIcon
size={15}
name="shield-check"
iconFamily="notesnook"
color={colors.secondary.icon}
/>
<Paragraph>{strings.yourSecurityIsPriority()}</Paragraph>
</View>
</View>
);
};

View File

@@ -24,6 +24,9 @@ import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import { hideAuth } from "./common";
import { AuthParams } from "../../stores/use-navigation-store";
import { ProgressPills } from "../intro/progress-pills";
import { FontSizes } from "../../common/design/font";
import { Spacing } from "../../common/design/spacing";
export const AuthHeader = (props: { welcome?: boolean }) => {
const { colors } = useThemeColors();
const route = useRoute();
@@ -37,19 +40,22 @@ export const AuthHeader = (props: { welcome?: boolean }) => {
<View
style={{
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 12,
paddingHorizontal: Spacing.LEVEL_3,
width: "100%",
height: 50,
justifyContent: !props.welcome ? "space-between" : "flex-end"
justifyContent: "space-between",
alignItems: "center"
}}
>
{props.welcome ? null : (
{props.welcome ? (
<ProgressPills activePillIndex={1} />
) : (
<IconButton
name="arrow-left"
name="arrow-back"
iconFamily="notesnook"
onPress={() => {
hideAuth((route.params as AuthParams)?.context);
}}
size={24}
color={colors.primary.paragraph}
/>
)}
@@ -60,16 +66,16 @@ export const AuthHeader = (props: { welcome?: boolean }) => {
onPress={() => {
hideAuth();
}}
iconSize={16}
iconSize={24}
fontSize={FontSizes.SM}
type="plain"
fontFamily="REGULAR"
iconPosition="right"
icon="chevron-right"
height={25}
iconStyle={{
marginTop: 2
}}
style={{
paddingHorizontal: 6
paddingRight: 0,
paddingHorizontal: 0,
paddingVertical: 0
}}
/>
)}

View File

@@ -17,15 +17,15 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React, { useState } from "react";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNavigationFocus } from "../../hooks/use-navigation-focus";
import { NavigationProps } from "../../services/navigation";
import { Toast } from "../toast";
import { AuthMode, initialAuthMode } from "./common";
import { Login } from "./login";
import { Signup } from "./signup";
import { useThemeColors } from "@notesnook/theme";
import { NavigationProps } from "../../services/navigation";
const Auth = ({ navigation, route }: NavigationProps<"Auth">) => {
const [currentAuthMode, setCurrentAuthMode] = useState(

View File

@@ -21,8 +21,10 @@ import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import { RouteProp, useRoute } from "@react-navigation/native";
import React, { useEffect, useState } from "react";
import { TouchableOpacity, View, useWindowDimensions } from "react-native";
import { TouchableOpacity, View } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { db } from "../../common/database";
import { Spacing } from "../../common/design/spacing";
import { DDS } from "../../services/device-detection";
import { eSendEvent, presentSheet } from "../../services/event-manager";
import Navigation from "../../services/navigation";
@@ -33,24 +35,24 @@ import { RouteParams } from "../../stores/use-navigation-store";
import { useUserStore } from "../../stores/use-user-store";
import { eUserLoggedIn } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { sleep } from "../../utils/time";
import { Dialog } from "../dialog";
import { ProgressPills } from "../intro/progress-pills";
import { Progress } from "../sheets/progress";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import FormInput, { validators } from "../ui/input/form-input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { hideAuth } from "./common";
import { ForgotPassword } from "./forgot-password";
import { AuthHeader } from "./header";
import TwoFactorVerification from "./two-factor";
import { useLogin } from "./use-login";
import FormInput, { validators } from "../ui/input/form-input";
import AppIcon from "../ui/AppIcon";
import { PASSWORD_PLACEHOLDER } from "../../utils/constants";
const LoginSteps = {
emailAuth: 1,
mfaAuth: 2,
passwordAuth: 3
mfaAuth: 2
};
export const Login = ({
@@ -68,8 +70,9 @@ export const Login = ({
passwordInputRef,
loading,
setLoading,
login,
error,
login,
mfaData,
formRef
} = useLogin(async () => {
eSendEvent(eUserLoggedIn, true);
@@ -91,8 +94,6 @@ export const Login = ({
Progress.present();
}
});
const { width, height } = useWindowDimensions();
const isTablet = width > 600;
const onContinue = () => {
login();
@@ -114,249 +115,257 @@ export const Login = ({
return (
<>
<AuthHeader />
<Dialog context="two_factor_verify" />
<View
style={{
paddingHorizontal: Spacing.LEVEL_3,
marginTop: Spacing.LEVEL_4
}}
>
<ProgressPills
count={2}
activePillIndex={step === LoginSteps.emailAuth ? 0 : 1}
/>
</View>
<KeyboardAwareScrollView
style={{
width: "100%"
}}
contentContainerStyle={{
minHeight: "90%"
minHeight: "99%"
}}
nestedScrollEnabled
enableAutomaticScroll={true}
keyboardShouldPersistTaps="handled"
>
<View
style={{
borderRadius: DDS.isTab ? 5 : 0,
backgroundColor: colors.primary.background,
zIndex: 10,
width: "100%",
height: "100%",
alignSelf: "center"
}}
>
{step === LoginSteps.emailAuth ? (
<View
style={{
justifyContent: "flex-end",
paddingHorizontal: DefaultAppStyles.GAP,
borderBottomWidth: 0.8,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
borderBottomColor: colors.primary.border,
alignSelf: isTablet ? "center" : undefined,
borderWidth: isTablet ? 1 : undefined,
borderColor: isTablet ? colors.primary.border : undefined,
borderRadius: isTablet ? 20 : undefined,
marginTop: isTablet ? 50 : undefined,
width: !isTablet ? undefined : "50%",
minHeight: height * 0.4
borderRadius: DDS.isTab ? 5 : 0,
backgroundColor: colors.primary.background,
zIndex: 10,
width: "100%",
alignSelf: "center",
height: "100%",
paddingHorizontal: Spacing.LEVEL_3,
paddingTop: Spacing.LEVEL_6
}}
>
<View
style={{
flexDirection: "row"
}}
>
<View
style={{
width: 100,
height: 5,
backgroundColor: colors.primary.accent,
borderRadius: 2,
marginRight: 7
}}
/>
<View
style={{
width: 20,
height: 5,
backgroundColor: colors.secondary.background,
borderRadius: 2
}}
/>
</View>
<Heading
style={{
marginBottom: 25,
marginTop: DefaultAppStyles.GAP_VERTICAL
paddingBottom: Spacing.LEVEL_4
}}
extraBold
size={AppFontSize.xxl}
fontSize="XL"
>
{strings.loginToYourAccount()}
</Heading>
</View>
<View
style={{
width: DDS.isTab
? focused
? "50%"
: "49.99%"
: focused
? "100%"
: "99.9%",
backgroundColor: colors.primary.background,
alignSelf: "center",
paddingHorizontal: DDS.isTab ? 0 : DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL
}}
>
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
testID="input.email"
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
keyboardType="email-address"
marginBottom={0}
autoCorrect={false}
autoCapitalize="none"
placeholder={strings.email()}
editable={step === LoginSteps.emailAuth && !loading}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
if (step === LoginSteps.emailAuth) {
onContinue();
} else {
passwordInputRef.current?.focus();
}
<View
style={{
width: DDS.isTab
? focused
? "50%"
: "49.99%"
: focused
? "100%"
: "99.9%",
backgroundColor: colors.primary.background,
alignSelf: "center",
gap: Spacing.LEVEL_2
}}
/>
{step === LoginSteps.passwordAuth && (
<>
<FormInput
name="password"
formRef={formRef}
fwdRef={passwordInputRef}
testID="input.password"
returnKeyLabel={strings.done()}
returnKeyType="done"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.password()}
marginBottom={0}
editable={!loading}
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={() => {
onContinue();
}}
/>
<Button
title={strings.forgotPassword()}
style={{
alignSelf: "flex-end",
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
paddingHorizontal: 0
}}
onPress={() => {
if (loading) return;
presentSheet({
component: (
<ForgotPassword
userEmail={formRef.current.getValue("email")}
/>
)
});
}}
textStyle={{
textDecorationLine: "underline"
}}
fontSize={AppFontSize.xs}
type="plain"
/>
</>
)}
<View>
<Button
loading={loading}
onPress={() => {
onContinue();
>
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
testID="input.email"
label={strings.email()}
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
keyboardType="email-address"
marginBottom={0}
autoCorrect={false}
autoCapitalize="none"
placeholder={strings.email()}
editable={step === LoginSteps.emailAuth && !loading}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
passwordInputRef.current?.focus();
}}
style={{
width: "100%"
}}
type="accent"
title={!loading ? strings.continue() : null}
fontSize={AppFontSize.sm}
/>
{step === LoginSteps.passwordAuth && (
<FormInput
name="password"
formRef={formRef}
fwdRef={passwordInputRef}
label={strings.password()}
testID="input.password"
returnKeyLabel={strings.done()}
returnKeyType="done"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={PASSWORD_PLACEHOLDER}
marginBottom={0}
editable={!loading}
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={() => {
onContinue();
}}
/>
<Button
title={strings.forgotPassword()}
style={{
alignSelf: "flex-end",
paddingVertical: 0,
paddingHorizontal: 0
}}
onPress={() => {
if (loading || !formRef.current.getValue("email")) return;
presentSheet({
component: (
<ForgotPassword
userEmail={formRef.current.getValue("email")}
/>
)
});
}}
fontFamily="REGULAR"
fontSize={AppFontSize.sm}
type="plain"
textStyle={{
color: colors.secondary.paragraph
}}
/>
<View
style={{
marginTop: Spacing.LEVEL_1,
gap: Spacing.LEVEL_2
}}
>
<Button
title={strings.cancelLogin()}
loading={loading}
onPress={() => {
if (loading) return;
login();
}}
style={{
alignSelf: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL,
width: "100%"
}}
onPress={() => {
if (loading) return;
setStep(LoginSteps.emailAuth);
setLoading(false);
}}
type="secondaryAccented"
type="accent"
title={!loading ? strings.continue() : null}
fontSize={AppFontSize.sm}
/>
)}
{!loading ? (
<TouchableOpacity
onPress={() => {
if (loading) return;
changeMode(1);
}}
activeOpacity={0.8}
style={{
alignSelf: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL,
paddingVertical: DefaultAppStyles.GAP_VERTICAL
}}
>
<Paragraph
size={AppFontSize.xs}
color={colors.secondary.paragraph}
{!loading ? (
<TouchableOpacity
onPress={() => {
if (loading) return;
changeMode(1);
}}
activeOpacity={0.8}
style={{
alignSelf: "center",
paddingVertical: 0
}}
>
{strings.dontHaveAccount()}{" "}
<Paragraph
size={AppFontSize.xs}
style={{ color: colors.primary.accent }}
>
{strings.signUp()}
<Paragraph fontSize="SM" color={colors.secondary.paragraph}>
{strings.dontHaveAccount()}{" "}
<Paragraph
fontSize="SM"
style={{ color: colors.primary.accent }}
fontFamily="SEMI_BOLD"
>
{strings.signUp()}
</Paragraph>
</Paragraph>
</Paragraph>
</TouchableOpacity>
) : null}
</TouchableOpacity>
) : null}
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<AppIcon
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error.message}
</Paragraph>
) : null}
style={{
textAlign: "center",
marginTop: Spacing.LEVEL_2
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error.message}
</Paragraph>
) : null}
</View>
</View>
</View>
</View>
) : (
<View
style={{
borderRadius: DDS.isTab ? 5 : 0,
backgroundColor: colors.primary.background,
zIndex: 10,
width: "100%",
alignSelf: "center",
height: "100%",
paddingHorizontal: Spacing.LEVEL_3,
paddingTop: Spacing.LEVEL_6
}}
>
<TwoFactorVerification
onMfaLogin={async (
mfa: any,
callback: (success: boolean) => void,
onerror: (error: Error) => void
) => {
try {
const success = await db.user.authenticateMultiFactorCode(
mfa.code,
mfa.method
);
await login();
if (success) {
setLoading(false);
callback && callback(true);
}
callback && callback(false);
} catch (e) {
callback && callback(false);
if ((e as Error).message === "invalid_grant") {
setLoading(false);
setStep(LoginSteps.emailAuth);
} else {
onerror(e as Error);
}
}
}}
mfaInfo={
mfaData.current || {
primaryMethod: "email",
secondaryMethod: "sms",
token: ""
}
}
onCancel={() => {
setLoading(false);
setStep(LoginSteps.emailAuth);
}}
/>
</View>
)}
</KeyboardAwareScrollView>
</>
);

View File

@@ -45,11 +45,12 @@ import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { LoginSteps, useLogin } from "./use-login";
import { useLogin } from "./use-login";
import { strings } from "@notesnook/intl";
import { getObfuscatedEmail } from "../../utils/functions";
import { DefaultAppStyles } from "../../utils/styles";
import FormInput, { validators } from "../ui/input/form-input";
import { PASSWORD_PLACEHOLDER } from "../../utils/constants";
export const SessionExpired = () => {
const { colors } = useThemeColors();
@@ -195,24 +196,22 @@ export const SessionExpired = () => {
</Paragraph>
</View>
{step === LoginSteps.passwordAuth ? (
<FormInput
fwdRef={passwordInputRef}
formRef={formRef}
name="password"
validators={[validators.required(strings.passwordRequired())]}
returnKeyLabel={strings.done()}
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.password()}
onSubmitEditing={() => {
login();
}}
/>
) : null}
<FormInput
fwdRef={passwordInputRef}
formRef={formRef}
name="password"
validators={[validators.required(strings.passwordRequired())]}
returnKeyLabel={strings.done()}
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={PASSWORD_PLACEHOLDER}
onSubmitEditing={() => {
login();
}}
/>
<Button
style={{

View File

@@ -21,12 +21,7 @@ import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import { RouteProp, useRoute } from "@react-navigation/native";
import React, { useRef, useState } from "react";
import {
TextInput,
TouchableOpacity,
View,
useWindowDimensions
} from "react-native";
import { TextInput, TouchableOpacity, View } from "react-native";
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { db } from "../../common/database";
import { DDS } from "../../services/device-detection";
@@ -46,6 +41,29 @@ import { SignupContext } from "./signup-context";
import { RouteParams } from "../../stores/use-navigation-store";
import SettingsService from "../../services/settings";
import AppIcon from "../ui/AppIcon";
import { Spacing } from "../../common/design/spacing";
import { SETTING_ACCOUNT_SVG } from "../../assets/images/assets";
import { ProgressPills } from "../intro/progress-pills";
import { sleep } from "../../utils/time";
import { PASSWORD_PLACEHOLDER } from "../../utils/constants";
const getSettingAccountSvg = (dark: boolean) => {
if (dark) return SETTING_ACCOUNT_SVG;
return SETTING_ACCOUNT_SVG.replace('stroke="#1F2722"', 'stroke="#E8F4ED"')
.replace(
'fill="#008836" fill-opacity="0.09" stroke="#233C2D"',
'fill="#008836" fill-opacity="0.04" stroke="#E3F1E8"'
)
.replace(
'fill="#008836" fill-opacity="0.09" stroke="#233C2D"',
'fill="#008836" fill-opacity="0.05" stroke="#E8F0EC"'
)
.replace(
'fill="#008836" fill-opacity="0.08" stroke="#233C2D"',
'fill="#008836" fill-opacity="0.06" stroke="#D3E8DB"'
);
};
const SignupSteps = {
signup: 0,
@@ -61,7 +79,7 @@ export const Signup = ({
welcome: boolean;
}) => {
const [currentStep, setCurrentStep] = useState(SignupSteps.signup);
const { colors } = useThemeColors();
const { colors, isDark } = useThemeColors();
const formRef = useRef(
createFormRef({
email: "",
@@ -76,8 +94,6 @@ export const Signup = ({
const [loading, setLoading] = useState(false);
const setUser = useUserStore((state) => state.setUser);
const setLastSynced = useUserStore((state) => state.setLastSynced);
const { width, height } = useWindowDimensions();
const isTablet = width > 600;
const route = useRoute<RouteProp<RouteParams, "Auth">>();
const signup = async () => {
@@ -89,8 +105,9 @@ export const Signup = ({
setLoading(true);
try {
setCurrentStep(SignupSteps.createAccount);
await db.user.signup(values.email.toLowerCase(), values.password);
await sleep(1000);
setCurrentStep(SignupSteps.createAccount);
const user = await db.user.getUser();
setUser(user);
setLastSynced(await db.lastSynced());
@@ -133,8 +150,9 @@ export const Signup = ({
width: "100%"
}}
contentContainerStyle={{
minHeight: "90%"
flex: 1
}}
bounces={false}
nestedScrollEnabled
keyboardShouldPersistTaps="handled"
>
@@ -145,68 +163,24 @@ export const Signup = ({
zIndex: 10,
width: "100%",
alignSelf: "center",
height: "100%"
height: "100%",
paddingHorizontal: Spacing.LEVEL_3,
paddingTop: Spacing.LEVEL_6
}}
>
<View
<Heading
style={{
justifyContent: "flex-end",
paddingHorizontal: 16,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
borderBottomWidth: 0.8,
borderBottomColor: colors.primary.border,
alignSelf: isTablet ? "center" : undefined,
borderWidth: isTablet ? 1 : undefined,
borderColor: isTablet ? colors.primary.border : undefined,
borderRadius: isTablet ? 20 : undefined,
marginTop: isTablet ? 50 : undefined,
width: !isTablet ? undefined : "50%",
minHeight: height * 0.25
paddingBottom: Spacing.LEVEL_4
}}
fontSize="XL"
>
<View
style={{
flexDirection: "row"
}}
>
<View
style={{
width: 100,
height: 5,
backgroundColor: colors.primary.accent,
borderRadius: 2,
marginRight: 7
}}
/>
<View
style={{
width: 20,
height: 5,
backgroundColor: colors.secondary.background,
borderRadius: 2
}}
/>
</View>
<Heading
extraBold
style={{
marginBottom: 25,
marginTop: 10
}}
size={AppFontSize.xxl}
>
{strings.createAccount()}
</Heading>
</View>
{strings.createAccount()}
</Heading>
<View
style={{
width: DDS.isTab ? "50%" : "100%",
paddingHorizontal: DDS.isTab ? 0 : 16,
backgroundColor: colors.primary.background,
flexGrow: 1,
alignSelf: "center"
gap: Spacing.LEVEL_2,
paddingBottom: Spacing.LEVEL_4
}}
>
<FormInput
@@ -214,6 +188,7 @@ export const Signup = ({
formRef={formRef}
fwdRef={emailInputRef}
loading={loading}
label={strings.email()}
testID="input.email"
returnKeyLabel="Next"
returnKeyType="next"
@@ -221,7 +196,7 @@ export const Signup = ({
keyboardType="email-address"
autoCorrect={false}
autoCapitalize="none"
placeholder={strings.email()}
placeholder="you@example.com"
blurOnSubmit={false}
validators={[
validators.required(strings.emailRequired()),
@@ -245,9 +220,11 @@ export const Signup = ({
autoCapitalize="none"
blurOnSubmit={false}
autoCorrect={false}
placeholder={strings.password()}
label={strings.password()}
placeholder={PASSWORD_PLACEHOLDER}
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={() => {
if (formRef.current.validateField("password")) return;
confirmPasswordInputRef.current?.focus();
}}
/>
@@ -265,8 +242,8 @@ export const Signup = ({
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
placeholder={strings.confirmPassword()}
marginBottom={12}
label={strings.confirmPassword()}
placeholder={PASSWORD_PLACEHOLDER}
validators={[
validators.required(strings.confirmPasswordRequired()),
validators.matchField(
@@ -278,7 +255,17 @@ export const Signup = ({
signup();
}}
/>
</View>
<View
style={{
width: DDS.isTab ? "50%" : "100%",
backgroundColor: colors.primary.background,
flexGrow: 1,
alignSelf: "center",
gap: Spacing.LEVEL_2
}}
>
<Button
title={!loading ? strings.continue() : null}
type="accent"
@@ -296,18 +283,21 @@ export const Signup = ({
}}
activeOpacity={0.8}
style={{
alignSelf: "center",
marginTop: 12,
paddingVertical: 12
alignSelf: "center"
}}
>
<Paragraph
size={AppFontSize.xs + 1}
color={colors.secondary.paragraph}
style={{
marginBottom: 25,
textAlign: "center"
}}
fontSize="SM"
color={colors.primary.paragraph}
>
{strings.alreadyHaveAccount()}{" "}
<Paragraph
size={AppFontSize.xs + 1}
fontSize="SM"
fontFamily="SEMI_BOLD"
style={{ color: colors.primary.accent }}
>
{strings.login()}
@@ -347,18 +337,16 @@ export const Signup = ({
marginBottom: 25,
textAlign: "center"
}}
size={AppFontSize.xxs}
fontSize="XS"
color={colors.secondary.paragraph}
>
{strings.signupAgreement[0]()}
<Paragraph
size={AppFontSize.xxs}
fontSize="XS"
onPress={() => {
openLinkInBrowser("https://notesnook.com/tos");
}}
style={{
textDecorationLine: "underline"
}}
fontFamily="SEMI_BOLD"
color={colors.primary.accent}
>
{" "}
@@ -366,31 +354,38 @@ export const Signup = ({
</Paragraph>{" "}
{strings.signupAgreement[2]()}
<Paragraph
size={AppFontSize.xxs}
fontSize="XS"
onPress={() => {
openLinkInBrowser("https://notesnook.com/privacy");
}}
style={{
textDecorationLine: "underline"
}}
fontFamily="SEMI_BOLD"
color={colors.primary.accent}
>
{" "}
{strings.signupAgreement[3]()}
</Paragraph>{" "}
{strings.signupAgreement[4]()}
</Paragraph>
</Paragraph>
</View>
</View>
</KeyboardAwareScrollView>
</>
) : (
<>
<View
style={{
paddingHorizontal: Spacing.LEVEL_3
}}
>
{welcome ? <ProgressPills activePillIndex={2} /> : null}
<Loading
title={"Setting up your account..."}
description="Your account is almost ready, please wait..."
title={strings.settingUpYourAccount()}
svgSrc={getSettingAccountSvg(isDark)}
description={strings.accountAlmostReady()}
style={{
height: undefined,
marginTop: Spacing.LEVEL_6
}}
/>
</>
</View>
)}
</SignupContext.Provider>
);

View File

@@ -20,19 +20,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { TextInput, View } from "react-native";
import { ScrollView } from "react-native-actions-sheet";
import { TextInput, TouchableOpacity, View } from "react-native";
import { db } from "../../common/database/index";
import { Radius, Spacing } from "../../common/design/spacing";
import useTimer from "../../hooks/use-timer";
import { eSendEvent, ToastManager } from "../../services/event-manager";
import { eCloseSimpleDialog } from "../../utils/events";
import { hexToRGBA, RGB_Linear_Shade } from "../../utils/colors";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { presentDialog } from "../dialog/functions";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import Input from "../ui/input";
import PinInput from "../ui/pin-input/index";
import { Pressable } from "../ui/pressable";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
@@ -73,11 +72,19 @@ const TwoFactorVerification = ({
const inputRef = useRef<TextInput>(null);
const [sending, setSending] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);
const [codeInput, setCodeInput] = useState("");
const isRecoveryCode = currentMethod.method === "recoveryCode";
const codeLength = isRecoveryCode ? 10 : 6;
const onNext = async () => {
if (!code.current || code.current.length < 6) {
setError(
new Error("Please provide a valid multi-factor authentication code.")
new Error(
currentMethod.method === "recoveryCode"
? strings.provideValidMultiFactorAuthRecoveryCode()
: strings.provideValidMultiFactorAuthCode()
)
);
return;
}
@@ -94,10 +101,7 @@ const TwoFactorVerification = ({
method: currentMethod.method,
code: code.current
},
(result) => {
if (result) {
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
}
() => {
setLoading(false);
},
(e) => {
@@ -118,12 +122,14 @@ const TwoFactorVerification = ({
{
id: "sms",
title: strings.sendCodeSms(),
icon: "message-plus-outline"
icon: "chat",
iconFamily: "notesnook"
},
{
id: "email",
title: strings.sendCodeEmail(),
icon: "email-outline"
icon: "envelope-simple",
iconFamily: "notesnook"
},
{
id: "app",
@@ -133,7 +139,8 @@ const TwoFactorVerification = ({
{
id: "recoveryCode",
title: strings.recoveryCode(),
icon: "key"
icon: "lock-simple",
iconFamily: "notesnook"
}
];
@@ -155,6 +162,7 @@ const TwoFactorVerification = ({
setSending(false);
} catch (e) {
setSending(false);
console.log("error e");
setError(
new Error(`Error sending 2FA Code. Tap "Send code" to try again `)
);
@@ -168,48 +176,56 @@ const TwoFactorVerification = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentMethod.method]);
useEffect(() => {
setCodeInput("");
code.current = "";
}, [currentMethod.method]);
return (
<ScrollView
keyboardShouldPersistTaps="handled"
keyboardDismissMode="interactive"
style={{
width: "100%",
height: "100%",
backgroundColor: colors.primary.background,
paddingTop: 60
}}
<View
onLayout={() => {
setTimeout(() => {
inputRef.current?.focus();
}, 500);
}}
style={{
alignItems: "center",
gap: Spacing.LEVEL_3
}}
>
<View
style={{
alignItems: "center",
paddingHorizontal: currentMethod.method ? 12 : 0,
gap: 12
paddingHorizontal: Spacing.LEVEL_3,
width: "100%"
}}
>
<IconButton
style={{
width: 70,
height: 70
width: 50,
height: 50,
backgroundColor: colors.primary.shade,
borderRadius: Radius.XS,
marginBottom: Spacing.LEVEL_7
}}
size={50}
name="key"
size={25}
iconFamily="notesnook"
name="shield-check"
color={colors.primary.accent}
/>
<Heading
style={{
textAlign: "center"
textAlign: "center",
marginBottom: Spacing.LEVEL_1
}}
>
{currentMethod.method ? strings["2fa"]() : strings.select2faMethod()}
{currentMethod.method === "recoveryCode"
? strings.recoveryCodeTitle()
: currentMethod.method
? strings["2fa"]()
: strings.select2faMethod()}
</Heading>
<Paragraph
style={{
width: "80%",
textAlign: "center"
}}
>
@@ -219,61 +235,35 @@ const TwoFactorVerification = ({
]?.() || strings.select2faCodeHelpText()
: strings.select2faCodeHelpText()}
</Paragraph>
</View>
{currentMethod.method === "sms" || currentMethod.method === "email" ? (
<Button
onPress={onSendCode}
type={seconds ? "plain" : "transparent"}
title={
sending
? ""
: `${
seconds
? strings.resend2faCode(`${seconds}`)
: strings.sendCode()
}`
}
loading={sending}
height={30}
/>
) : null}
{currentMethod.method ? (
<>
<Input
placeholder={
currentMethod.method === "recoveryCode"
? "xxxxx-xxxxx"
: "xxxxxx"
}
{currentMethod.method ? (
<>
<View
style={{
borderRadius: Radius.S,
width: "100%",
gap: Spacing.LEVEL_4
}}
>
<PinInput
testID={"input.totp"}
maxLength={
currentMethod.method === "recoveryCode" ? undefined : 6
}
fwdRef={inputRef}
textAlign="center"
onChangeText={(value) => {
setError(undefined);
inputRef={inputRef}
length={codeLength}
value={codeInput}
onChangeText={(value: string) => {
setCodeInput(value);
code.current = value;
}}
cursorColor={colors.selected.accent}
selectionHandleColor={colors.selected.accent}
selectionColor={colors.selected.accent}
onSubmitEditing={onNext}
height={60}
marginBottom={0}
inputStyle={{
fontSize: AppFontSize.lg,
textAlign: "center",
letterSpacing: 7,
width: 250
}}
keyboardType={
currentMethod.method === "recoveryCode" ? "default" : "numeric"
}
enablesReturnKeyAutomatically
containerStyle={{
minWidth: "50%"
keyboardType={isRecoveryCode ? "web-search" : "number-pad"}
showDashAfterDigit={isRecoveryCode ? 5 : undefined}
sanitize={(value: string) => {
if (isRecoveryCode) {
return value.replace(/[^0-9a-zA-Z]/g, "").toUpperCase();
}
return value.replace(/[^0-9]/g, "");
}}
/>
{error ? (
@@ -284,7 +274,9 @@ const TwoFactorVerification = ({
style={{
textAlign: "center",
marginVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
maxWidth: 250
maxWidth: 250,
alignSelf: "center",
marginTop: -10
}}
>
<AppIcon
@@ -296,76 +288,132 @@ const TwoFactorVerification = ({
</Paragraph>
) : null}
<Button
title={loading ? null : strings.next()}
type="accent"
width={250}
loading={loading}
onPress={onNext}
/>
<Button
title={strings.cancel()}
type="secondaryAccented"
onPress={() => {
reset();
onCancel();
<View
style={{
flexDirection: "row",
gap: Spacing.LEVEL_2
}}
width={250}
/>
<Button
title={strings["2faCodeSecondaryMethodText"][
currentMethod.method as keyof (typeof strings)["2faCodeSecondaryMethodText"]
]()}
type="plain"
onPress={onRequestSecondaryMethod}
height={30}
/>
</>
) : (
<>
{getMethods().map((item) => (
<Pressable
key={item.title}
>
<Button
title={strings.cancel()}
type="secondary"
onPress={() => {
setCurrentMethod({
method: item.id,
isPrimary: false
});
reset();
onCancel();
}}
style={{
paddingHorizontal: DefaultAppStyles.GAP,
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
marginTop: 0,
flexDirection: "row",
borderRadius: 0,
alignItems: "center",
width: "100%",
justifyContent: "flex-start"
width: "48%"
}}
/>
<Button
title={loading ? null : strings.continue()}
type="accent"
loading={loading}
onPress={onNext}
style={{
width: "48%"
}}
/>
</View>
{currentMethod.method === "sms" ||
currentMethod.method === "email" ? (
<Button
onPress={onSendCode}
type={"plain"}
disabled={parseInt(`${seconds}`) !== 0}
title={
sending
? ""
: `${
seconds
? strings.resend2faCode(`${seconds}`)
: strings.resendCode()
}`
}
loading={sending}
fontSize={AppFontSize.sm}
fontFamily="REGULAR"
style={{
paddingVertical: 0,
alignSelf: "flex-start",
paddingHorizontal: 0
}}
/>
) : null}
</View>
<TouchableOpacity
onPress={onRequestSecondaryMethod}
activeOpacity={0.8}
style={{
alignSelf: "center",
paddingVertical: 0,
marginTop: Spacing.LEVEL_3
}}
>
<Paragraph fontSize="SM" color={colors.secondary.paragraph}>
{strings["2faCodeSecondaryMethodText"][
currentMethod.method as keyof (typeof strings)["2faCodeSecondaryMethodText"]
]()}
</Paragraph>
</TouchableOpacity>
</>
) : (
<View
style={{
gap: Spacing.LEVEL_2,
width: "100%"
}}
>
{getMethods().map((item) => (
<Pressable
key={item.title}
onPress={() => {
setCurrentMethod({
method: item.id,
isPrimary: false
});
}}
style={{
padding: Spacing.LEVEL_2,
backgroundColor: colors.secondary.background,
flexDirection: "row",
borderRadius: Radius.S,
alignItems: "center",
width: "100%",
justifyContent: "flex-start",
gap: Spacing.LEVEL_1
}}
>
<IconButton
style={{
borderRadius: Radius.XS,
padding: Spacing.LEVEL_1,
width: undefined,
height: undefined,
backgroundColor: RGB_Linear_Shade(
0.04,
hexToRGBA(colors.secondary.background)
)
}}
size={17}
color={colors.primary.icon}
name={item.icon}
iconFamily={item.iconFamily as "notesnook"}
/>
<View
style={{
flexShrink: 1
}}
>
<IconButton
type="secondaryAccented"
style={{
marginRight: 10
}}
size={15}
color={colors.primary.accent}
name={item.icon}
/>
<View
style={{
flexShrink: 1
}}
>
<Paragraph size={AppFontSize.md}>{item.title}</Paragraph>
</View>
</Pressable>
))}
</>
)}
</View>
</ScrollView>
<Paragraph size={AppFontSize.sm}>{item.title}</Paragraph>
</View>
</Pressable>
))}
</View>
)}
</View>
);
};

View File

@@ -21,19 +21,16 @@ import { strings } from "@notesnook/intl";
import { useRef, useState } from "react";
import { TextInput } from "react-native";
import { db } from "../../common/database";
import { ToastManager, eSendEvent } from "../../services/event-manager";
import { ToastManager } from "../../services/event-manager";
import { clearMessage } from "../../services/message";
import PremiumService from "../../services/premium";
import SettingsService from "../../services/settings";
import { useUserStore } from "../../stores/use-user-store";
import { eCloseSimpleDialog } from "../../utils/events";
import TwoFactorVerification from "./two-factor";
import { createFormRef } from "../ui/input/form-input";
export const LoginSteps = {
emailAuth: 1,
mfaAuth: 2,
passwordAuth: 3
mfaAuth: 2
};
export const useLogin = (
@@ -46,6 +43,7 @@ export const useLogin = (
const [step, setStep] = useState(LoginSteps.emailAuth);
const emailInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const mfaData = useRef<any>(undefined);
const formRef = useRef(
createFormRef({
email: "",
@@ -60,57 +58,23 @@ export const useLogin = (
setLoading(true);
switch (step) {
case LoginSteps.emailAuth: {
if (formRef.current.validateField("email")) {
if (!formRef.current.validate()) {
setLoading(false);
return;
}
const mfaInfo = await db.user.authenticateEmail(
formRef.current.getValue("email")
);
if (mfaInfo) {
TwoFactorVerification.present(
async (mfa: any, callback: (success: boolean) => void, onerror: (e: Error) => void) => {
try {
const success = await db.user.authenticateMultiFactorCode(
mfa.code,
mfa.method
);
if (success) {
setStep(LoginSteps.passwordAuth);
setLoading(false);
setTimeout(() => {
passwordInputRef.current?.focus();
}, 500);
callback && callback(true);
}
callback && callback(false);
} catch (e) {
callback && callback(false);
if ((e as Error).message === "invalid_grant") {
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
setLoading(false);
setStep(LoginSteps.emailAuth);
ToastManager.error(new Error("Token expired, try logging in again"));
} else {
onerror(e as Error);
}
}
},
mfaInfo,
() => {
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
setLoading(false);
setStep(LoginSteps.emailAuth);
}
);
mfaData.current = mfaInfo;
setStep(LoginSteps.mfaAuth);
setLoading(false);
} else {
finishWithError(new Error(strings.unableToSend2faCode()));
}
break;
}
case LoginSteps.passwordAuth: {
case LoginSteps.mfaAuth: {
if (!formRef.current.validate()) {
setLoading(false);
return;
@@ -172,6 +136,7 @@ export const useLogin = (
setLoading,
error,
setError,
formRef
formRef,
mfaData
};
};

View File

@@ -19,7 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import React from "react";
import { useThemeColors } from "@notesnook/theme";
import { useRef } from "react";
import { View } from "react-native";
import { useWindowDimensions, View } from "react-native";
import { defaultBorderRadius } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import DatePicker from "react-native-date-picker";
@@ -32,7 +32,10 @@ export default function DatePickerComponent(props: {
onCancel: () => void;
}) {
const { colors, isDark } = useThemeColors();
const dateRef = useRef<Date>(dayjs().add(1, "day").toDate());
const dateRef = useRef<Date>(dayjs().add(1, "week").toDate());
const { width } = useWindowDimensions();
return (
<View
style={{
@@ -46,6 +49,9 @@ export default function DatePickerComponent(props: {
}}
>
<DatePicker
style={{
width: width * 0.8 - DefaultAppStyles.GAP * 2
}}
theme={isDark ? "dark" : "light"}
mode="date"
minimumDate={dayjs().add(1, "day").toDate()}

View File

@@ -31,8 +31,7 @@ import ImagePreview from "../image-preview";
import MergeConflicts from "../merge-conflicts";
import SheetProvider from "../sheet-provider";
import RateAppSheet from "../sheets/rate-app";
import RecoveryKeySheet from "../sheets/recovery-key";
import Progress from "../dialogs/progress";
import Progress from "../dialogs/progress/progress";
import { useSettingStore } from "../../stores/use-setting-store";
const DialogProvider = () => {
@@ -51,7 +50,6 @@ const DialogProvider = () => {
{isAppLoading ? null : (
<>
<MergeConflicts />
<RecoveryKeySheet colors={colors} />
<VaultDialog colors={colors} />
<RateAppSheet />
<ImagePreview />

View File

@@ -20,14 +20,11 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { ActivityIndicator, StyleSheet, View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { View } from "react-native";
import { notesnook } from "../../../e2e/test.ids";
import { getColorLinearShade } from "../../utils/colors";
import { Spacing } from "../../common/design/spacing";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Button, ButtonProps } from "../ui/button";
import Paragraph from "../ui/typography/paragraph";
const DialogButtons = ({
onPressPositive,
@@ -51,82 +48,41 @@ const DialogButtons = ({
return (
<View
style={[
styles.container,
{
backgroundColor: colors.secondary.background,
height: 60,
paddingHorizontal: DefaultAppStyles.GAP,
borderTopWidth: 0.7,
borderTopColor: getColorLinearShade(
colors.secondary.background,
0.05,
isDark
)
flexDirection: "row",
gap: Spacing.LEVEL_2,
paddingHorizontal: Spacing.LEVEL_3
}
]}
>
{doneText ? (
<View
style={{
flexDirection: "row",
alignItems: "center"
}}
>
<Icon
color={colors.primary.accent}
name="check-circle-outline"
size={AppFontSize.md}
/>
<Paragraph color={colors.primary.accent}>{" " + doneText}</Paragraph>
</View>
) : loading ? (
<ActivityIndicator
size={AppFontSize.lg}
color={colors.primary.accent}
/>
) : (
<View />
)}
<View
<Button
onPress={onPressNegative}
fontSize={AppFontSize.md}
testID={notesnook.ids.default.dialog.no}
type="plain-outline"
style={{
flexDirection: "row",
alignItems: "center"
flexGrow: 1,
flexShrink: 1
}}
>
title={negativeTitle}
/>
{onPressPositive ? (
<Button
onPress={onPressNegative}
fontSize={AppFontSize.sm}
testID={notesnook.ids.default.dialog.no}
type="plain"
onPress={onPressPositive}
fontSize={AppFontSize.md}
testID={notesnook.ids.default.dialog.yes}
style={{
flexGrow: 1,
flexShrink: 1
}}
loading={loading}
bold
title={negativeTitle}
type={positiveType || "accent"}
title={positiveTitle}
/>
{onPressPositive ? (
<Button
onPress={onPressPositive}
fontSize={AppFontSize.sm}
testID={notesnook.ids.default.dialog.yes}
style={{
marginLeft: 10
}}
bold
type={positiveType || "transparent"}
title={positiveTitle}
/>
) : null}
</View>
) : null}
</View>
);
};
export default DialogButtons;
const styles = StyleSheet.create({
container: {
justifyContent: "space-between",
alignItems: "center",
flexDirection: "row",
marginTop: DefaultAppStyles.GAP_VERTICAL
}
});

View File

@@ -23,6 +23,7 @@ import { DDS } from "../../services/device-detection";
import { useThemeColors } from "@notesnook/theme";
import { getElevationStyle } from "../../utils/elevation";
import { getContainerBorder } from "../../utils/colors";
import { Radius } from "../../common/design/spacing";
const DialogContainer = ({
width,
@@ -44,9 +45,10 @@ const DialogContainer = ({
{
width: width || DDS.isTab ? 500 : "85%",
maxHeight: height || 450,
borderRadius: 10,
borderRadius: Radius.LG,
backgroundColor: colors.primary.background,
paddingTop: 12
paddingTop: 12,
marginTop: -300
},
restProps?.noBorder
? {}

View File

@@ -17,17 +17,20 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { Text, View, ViewStyle } from "react-native";
import { useThemeColors } from "@notesnook/theme";
import { AppFontSize } from "../../utils/size";
import { Radius, Spacing } from "../../common/design/spacing";
import { DefaultAppStyles } from "../../utils/styles";
import AppIcon, { IconProps } from "../ui/AppIcon";
import { Button, ButtonProps } from "../ui/button";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { DefaultAppStyles } from "../../utils/styles";
type DialogHeaderProps = {
icon?: string;
iconFamily?: IconProps["iconFamily"];
iconType?: "error" | "normal";
title?: string;
paragraph?: string;
button?: ButtonProps;
@@ -46,7 +49,10 @@ const DialogHeader = ({
padding,
centered,
titlePart,
style
style,
icon,
iconFamily,
iconType
}: DialogHeaderProps) => {
const { colors } = useThemeColors();
@@ -57,61 +63,89 @@ const DialogHeader = ({
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
minHeight: 50,
paddingHorizontal: padding,
...style
}}
>
<View
style={{
width: "100%"
width: "100%",
gap: Spacing.LEVEL_4
}}
>
{icon ? (
<View
style={{
alignSelf: centered ? "center" : "flex-start",
width: 40,
height: 40,
backgroundColor: colors.error.shade,
justifyContent: "center",
alignItems: "center",
borderRadius: Radius.XS
}}
>
<AppIcon
name={icon}
iconFamily={iconFamily}
color={
iconType == "error" ? colors.static.red : colors.primary.icon
}
/>
</View>
) : null}
<View
style={{
flexDirection: "row",
justifyContent: centered ? "center" : "space-between",
alignItems: "center"
gap: Spacing.LEVEL_1
}}
>
<Heading
style={{ textAlign: centered ? "center" : "left" }}
size={AppFontSize.lg}
<View
style={{
flexDirection: "row",
justifyContent: centered ? "center" : "space-between",
alignItems: "center"
}}
>
{title}{" "}
{titlePart ? (
<Text style={{ color: colors.primary.accent }}>
{titlePart}
</Text>
) : null}
</Heading>
<Heading
style={{ textAlign: centered ? "center" : "left" }}
fontSize="XL"
>
{title}{" "}
{titlePart ? (
<Text style={{ color: colors.primary.accent }}>
{titlePart}
</Text>
) : null}
</Heading>
{button ? (
<Button
{button ? (
<Button
style={{
borderRadius: 100,
paddingHorizontal: DefaultAppStyles.GAP
}}
fontSize={13}
type={button.type || "secondary"}
height={30}
{...button}
/>
) : null}
</View>
{paragraph ? (
<Paragraph
style={{
borderRadius: 100,
paddingHorizontal: DefaultAppStyles.GAP
textAlign: centered ? "center" : "left",
maxWidth: centered ? "90%" : "100%",
alignSelf: centered ? "center" : "flex-start"
}}
fontSize={13}
type={button.type || "secondary"}
height={30}
{...button}
/>
color={paragraphColor || colors.secondary.paragraph}
>
{paragraph}
</Paragraph>
) : null}
</View>
{paragraph ? (
<Paragraph
style={{
textAlign: centered ? "center" : "left",
maxWidth: centered ? "90%" : "100%",
alignSelf: centered ? "center" : "flex-start"
}}
color={paragraphColor || colors.secondary.paragraph}
>
{paragraph}
</Paragraph>
) : null}
</View>
</View>
</>

View File

@@ -23,10 +23,12 @@ import { eCloseSimpleDialog, eOpenSimpleDialog } from "../../utils/events";
import { ButtonProps } from "../ui/button";
import { FieldValidator, FormRef } from "../ui/input/form-input";
import { RefObject } from "react";
import { IconProps } from "../ui/AppIcon";
export type DialogInfo = {
title?: string;
paragraph?: string;
centered?: boolean;
positiveText: string;
negativeText: string;
background?: string;
@@ -44,6 +46,8 @@ export type DialogInfo = {
| "error"
| "errorShade";
icon?: string;
iconFamily?: IconProps["iconFamily"];
iconType?: "error" | "normal";
paragraphColor: string;
form?: {
formRef: FormRef;
@@ -58,6 +62,7 @@ export type DialogInfo = {
onFormSubmit?: (form: FormRef) => Promise<boolean>;
};
input: boolean;
inputLabel?: string;
inputPlaceholder: string;
defaultValue: string;
// eslint-disable-next-line @typescript-eslint/ban-types

View File

@@ -19,13 +19,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useThemeColors } from "@notesnook/theme";
import React, {
RefObject,
useCallback,
useEffect,
useRef,
useState,
RefObject
useState
} from "react";
import { TextInput, View, ViewStyle } from "react-native";
import { Spacing } from "../../common/design/spacing";
import { DDS } from "../../services/device-detection";
import {
eSubscribeEvent,
@@ -42,7 +43,6 @@ import { Button } from "../ui/button";
import Input from "../ui/input";
import { FormInput, type FormRef } from "../ui/input/form-input";
import { Notice } from "../ui/notice";
import Seperator from "../ui/seperator";
import BaseDialog from "./base-dialog";
import DialogButtons from "./dialog-buttons";
import DialogHeader from "./dialog-header";
@@ -143,9 +143,6 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
}, [hide, show]);
const onNegativePress = async () => {
if (dialogInfo?.onClose) {
await dialogInfo.onClose();
}
hide();
};
@@ -155,7 +152,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
maxHeight: 450,
borderRadius: defaultBorderRadius,
backgroundColor: colors.primary.background,
paddingTop: 12,
gap: Spacing.LEVEL_4,
...getContainerBorder(colors.primary.border, 0.5),
overflow: "hidden"
};
@@ -170,9 +167,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
bounce={!dialogInfo.input && !dialogInfo.form}
closeOnTouch={!dialogInfo.disableBackdropClosing}
background={dialogInfo.background}
transparent={
dialogInfo.transparent === undefined ? false : dialogInfo.transparent
}
transparent={dialogInfo.transparent}
onShow={async () => {
if (dialogInfo.input && !dialogInfo.form) {
inputRef.current?.setNativeProps({
@@ -201,54 +196,65 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
: dialogInfo.component}
{dialogInfo.component ? null : (
<View style={style}>
<DialogHeader
title={dialogInfo.title}
icon={dialogInfo.icon}
paragraph={dialogInfo.paragraph}
paragraphColor={dialogInfo.paragraphColor}
padding={12}
style={{
minHeight: 0
}}
/>
<Seperator half />
<View
style={[
{
paddingVertical: Spacing.LEVEL_4
},
style
]}
>
<View
style={[
{
paddingHorizontal: Spacing.LEVEL_3,
gap: Spacing.LEVEL_4
}
]}
>
<DialogHeader
title={dialogInfo.title}
icon={dialogInfo.icon}
iconFamily={dialogInfo.iconFamily}
iconType={dialogInfo.iconType}
centered={dialogInfo.centered}
paragraph={dialogInfo.paragraph}
paragraphColor={dialogInfo.paragraphColor}
style={{
minHeight: 0
}}
/>
{dialogInfo.form ? (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP / 2
}}
>
{dialogInfo.form.items.map((item, index) => (
<FormInput
key={item.name}
fwdRef={item.ref}
name={item.name}
autoFocus={index === 0}
placeholder={item.placeholder}
formRef={formRef as RefObject<FormRef>}
validators={item.validators}
defaultValue={item.defaultValue}
secureTextEntry={dialogInfo.secureTextEntry}
onSubmitEditing={() => {
const nextItem = dialogInfo?.form?.items?.[index + 1];
if (nextItem) {
nextItem?.ref.current?.focus();
} else {
onPressPositive();
}
}}
/>
))}
</View>
) : dialogInfo.input ? (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP
}}
>
{dialogInfo.form ? (
<View
style={{
gap: Spacing.LEVEL_2
}}
>
{dialogInfo.form.items.map((item, index) => (
<FormInput
key={item.name}
fwdRef={item.ref}
name={item.name}
label={item.label}
autoFocus={index === 0}
placeholder={item.placeholder}
formRef={formRef as RefObject<FormRef>}
validators={item.validators}
defaultValue={item.defaultValue}
secureTextEntry={dialogInfo.secureTextEntry}
onSubmitEditing={() => {
const nextItem = dialogInfo?.form?.items?.[index + 1];
if (nextItem) {
nextItem?.ref.current?.focus();
} else {
onPressPositive();
}
}}
/>
))}
</View>
) : dialogInfo.input ? (
<Input
fwdRef={inputRef}
autoCapitalize="none"
@@ -258,6 +264,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
testID="input-value"
secureTextEntry={dialogInfo.secureTextEntry}
defaultValue={dialogInfo.defaultValue}
label={dialogInfo.inputLabel}
onSubmit={() => {
onPressPositive();
}}
@@ -266,48 +273,47 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
keyboardType={dialogInfo.keyboardType || "default"}
placeholder={dialogInfo.inputPlaceholder}
/>
</View>
) : null}
) : null}
{dialogInfo?.notice ? (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP
}}
>
<Notice
type={dialogInfo.notice.type || "information"}
text={dialogInfo.notice.text}
/>
</View>
) : null}
{dialogInfo.check ? (
<>
<Button
onPress={() => {
setChecked(!checked);
}}
icon={
checked
? "check-circle-outline"
: "checkbox-blank-circle-outline"
}
iconColor={
checked ? colors.secondary.icon : colors.primary.icon
}
{dialogInfo?.notice ? (
<View
style={{
justifyContent: "flex-start"
paddingHorizontal: DefaultAppStyles.GAP
}}
height={35}
iconSize={20}
width="100%"
title={dialogInfo.check.info}
type={checked ? dialogInfo.check.type || "plain" : "plain"}
/>
</>
) : null}
>
<Notice
type={dialogInfo.notice.type || "information"}
text={dialogInfo.notice.text}
/>
</View>
) : null}
{dialogInfo.check ? (
<>
<Button
onPress={() => {
setChecked(!checked);
}}
icon={
checked
? "check-circle-outline"
: "checkbox-blank-circle-outline"
}
iconColor={
checked ? colors.secondary.icon : colors.primary.icon
}
style={{
justifyContent: "flex-start"
}}
height={35}
iconSize={20}
width="100%"
title={dialogInfo.check.info}
type={checked ? dialogInfo.check.type || "plain" : "plain"}
/>
</>
) : null}
</View>
<DialogButtons
onPressNegative={onNegativePress}
onPressPositive={

View File

@@ -16,9 +16,11 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useRef, useState } from "react";
import { TextInput, View } from "react-native";
import { Radius, Spacing } from "../../../common/design/spacing";
import { db } from "../../../common/database";
import {
clearAppLockVerificationCipher,
@@ -33,7 +35,7 @@ import {
eSubscribeEvent
} from "../../../services/event-manager";
import SettingsService from "../../../services/settings";
import { useSettingStore } from "../../../stores/use-setting-store";
import { PASSWORD_PLACEHOLDER } from "../../../utils/constants";
import { getElevationStyle } from "../../../utils/elevation";
import {
eCloseAppLocKPasswordDailog,
@@ -42,61 +44,122 @@ import {
import { AppFontSize } from "../../../utils/size";
import { sleep } from "../../../utils/time";
import BaseDialog from "../../dialog/base-dialog";
import DialogButtons from "../../dialog/dialog-buttons";
import DialogHeader from "../../dialog/dialog-header";
import { Toast } from "../../toast";
import { Button } from "../../ui/button";
import { IconButton } from "../../ui/icon-button";
import Input from "../../ui/input";
import Seperator from "../../ui/seperator";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../../utils/styles";
import FormInput, {
createFormRef,
validators
} from "../../ui/input/form-input";
import Heading from "../../ui/typography/heading";
export const AppLockPassword = () => {
const { colors } = useThemeColors();
const [mode, setMode] = useState<"create" | "change" | "remove">("create");
const [keyboardType, setKeyboardType] = useState<"pin" | "password">(
useSettingStore.getState().settings.applockKeyboardType === "default"
? "password"
: "pin"
);
const [visible, setVisible] = useState(false);
const currentPasswordInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const confirmPasswordInputRef = useRef<TextInput>(null);
const values = useRef<{
currentPassword?: string;
password?: string;
confirmPassword?: string;
}>({});
const [secureTextEntry, setSecureTextEntry] = useState(true);
const formRef = useRef(
createFormRef({
currentPassword: "",
password: "",
confirmPassword: ""
})
);
const [accountPass, setAccountPass] = useState(false);
const enableApplock = useRef(false);
useEffect(() => {
const subs = [
eSubscribeEvent(
eOpenAppLockPasswordDialog,
(mode: "create" | "change" | "remove") => {
(mode: "create" | "change" | "remove", _enableApplock = false) => {
setMode(mode);
setAccountPass(false);
setVisible(true);
enableApplock.current = _enableApplock;
}
),
eSubscribeEvent(eCloseAppLocKPasswordDailog, () => {
values.current = {};
setVisible(false);
close();
})
];
return () => {
subs.forEach((sub) => sub?.unsubscribe());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const close = () => {
values.current = {};
formRef.current.setValue("currentPassword", "");
formRef.current.setValue("password", "");
formRef.current.setValue("confirmPassword", "");
formRef.current.clearErrors();
setVisible(false);
};
const onSubmit = async () => {
if (!formRef.current.validate()) return;
const currentPassword = formRef.current.getValue("currentPassword");
const password = formRef.current.getValue("password");
if (mode === "create") {
setAppLockVerificationCipher(password);
SettingsService.setProperty("appLockHasPasswordSecurity", true);
if (enableApplock.current) {
SettingsService.setProperty("appLockEnabled", true);
}
} else if (mode === "change") {
const isCurrentPasswordCorrect =
await validateAppLockPassword(currentPassword);
if (!isCurrentPasswordCorrect) {
formRef.current.setError(
"currentPassword",
strings.incorrect("password")
);
return;
}
await clearAppLockVerificationCipher();
SettingsService.setProperty("appLockHasPasswordSecurity", true);
await setAppLockVerificationCipher(password);
} else if (mode === "remove") {
const isCurrentPasswordCorrect = accountPass
? await db.user.verifyPassword(password)
: await validateAppLockPassword(password);
if (!isCurrentPasswordCorrect) {
formRef.current.setError(
"password",
accountPass
? strings.passwordIncorrect()
: strings.incorrect("password")
);
return;
}
clearAppLockVerificationCipher();
SettingsService.setProperty("appLockHasPasswordSecurity", false);
if (
!(await BiometricService.isBiometryAvailable()) ||
SettingsService.getProperty("biometricsAuthEnabled") === false
) {
SettingsService.setProperty("appLockEnabled", false);
SettingsService.setPrivacyScreen(
SettingsService.getProperty("privacyScreen")
);
ToastManager.show({
message: strings.applockDisabled(),
type: "success"
});
}
}
close();
};
return !visible ? null : (
<BaseDialog
onShow={async () => {
@@ -115,286 +178,132 @@ export const AppLockPassword = () => {
style={{
...getElevationStyle(10),
width: DDS.isTab ? 350 : "85%",
borderRadius: 10,
borderRadius: Radius.MD,
backgroundColor: colors.primary.background,
paddingTop: 12
paddingHorizontal: Spacing.LEVEL_3,
paddingVertical: Spacing.LEVEL_4,
gap: Spacing.LEVEL_4
}}
>
<DialogHeader
title={strings.changeAppLockCredentials(mode, keyboardType)}
icon="shield"
padding={12}
/>
<Seperator half />
<Heading fontSize="XL" lineHeight="100%">
{strings.changeAppLockCredentials(mode, "password")}
</Heading>
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP
}}
>
<View style={{ gap: Spacing.LEVEL_2 }}>
{mode === "change" ? (
<Input
<FormInput
name="currentPassword"
formRef={formRef}
label={strings.currentPassword()}
fwdRef={currentPasswordInputRef}
autoCapitalize="none"
onChangeText={(value) => {
values.current.currentPassword = value;
}}
onSubmit={() => {
passwordInputRef.current?.focus();
}}
defaultValue={values.current.currentPassword}
autoComplete="password"
onSubmitEditing={() => passwordInputRef.current?.focus()}
returnKeyLabel={strings.next()}
keyboardType={keyboardType === "pin" ? "number-pad" : "default"}
returnKeyType="next"
secureTextEntry={secureTextEntry}
placeholder={
keyboardType === "pin"
? strings.currentPin()
: strings.currentPassword()
}
secureTextEntry
placeholder={PASSWORD_PLACEHOLDER}
containerStyle={{ borderRadius: Radius.XS }}
validators={[validators.required(strings.passwordRequired())]}
/>
) : null}
<Input
<FormInput
name="password"
formRef={formRef}
label={
mode === "change"
? strings.newPassword()
: strings.enterPassword()
}
fwdRef={passwordInputRef}
autoCapitalize="none"
onChangeText={(value) => {
values.current.password = value;
}}
onSubmit={() => {
confirmPasswordInputRef.current?.focus();
}}
defaultValue={values.current.password}
keyboardType={
keyboardType === "pin" && !accountPass ? "number-pad" : "default"
}
autoComplete="password"
onSubmitEditing={() => {
if (mode !== "remove") {
confirmPasswordInputRef.current?.focus();
} else {
onSubmit();
}
}}
returnKeyLabel={
mode !== "remove" ? strings.next() : strings.remove()
}
returnKeyType={mode !== "remove" ? "next" : "done"}
secureTextEntry={secureTextEntry}
buttonLeft={
accountPass ? null : (
<IconButton
name={keyboardType === "password" ? "numeric" : "keyboard"}
onPress={() => {
setKeyboardType(
keyboardType === "password" ? "pin" : "password"
);
setSecureTextEntry(false);
setImmediate(() => {
setSecureTextEntry(true);
});
}}
style={{
width: 25,
height: 25,
marginRight: 5
}}
size={AppFontSize.lg}
/>
)
}
placeholder={
accountPass
? strings.enterAccountPassword()
: mode === "change"
? keyboardType === "pin"
? strings.newPin()
: strings.newPassword()
: `${
keyboardType === "pin"
? strings.pin()
: strings.password()
}`
}
secureTextEntry
placeholder={PASSWORD_PLACEHOLDER}
containerStyle={{ borderRadius: Radius.XS }}
validators={[validators.required(strings.passwordRequired())]}
/>
{mode !== "remove" ? (
<Input
fwdRef={confirmPasswordInputRef}
autoCapitalize="none"
onChangeText={(value) => {
values.current.confirmPassword = value;
}}
onSubmit={() => {
confirmPasswordInputRef.current?.focus();
}}
defaultValue={values.current.confirmPassword}
keyboardType={keyboardType === "pin" ? "number-pad" : "default"}
customValidator={() => values.current.password || ""}
validationType="confirmPassword"
autoComplete="password"
returnKeyLabel={strings.done()}
returnKeyType="done"
secureTextEntry={secureTextEntry}
placeholder={
keyboardType === "pin"
? strings.confirmPin()
<FormInput
name="confirmPassword"
formRef={formRef}
label={
mode === "change"
? strings.confirmNewPassword()
: strings.confirmPassword()
}
fwdRef={confirmPasswordInputRef}
autoCapitalize="none"
autoComplete="password"
onSubmitEditing={() => onSubmit()}
returnKeyLabel={strings.done()}
returnKeyType="done"
secureTextEntry
placeholder={PASSWORD_PLACEHOLDER}
containerStyle={{ borderRadius: Radius.XS }}
validators={[
validators.required(strings.confirmPasswordRequired()),
validators.matchField("password", strings.passwordNotMatched())
]}
/>
) : null}
{mode === "remove" ? (
<>
<Button
icon={
accountPass ? "checkbox-marked" : "checkbox-blank-outline"
}
onPress={() => {
setSecureTextEntry(false);
setAccountPass(!accountPass);
setTimeout(() => {
setSecureTextEntry(true);
});
}}
iconSize={AppFontSize.lg}
type="plain"
iconColor={
accountPass ? colors.primary.accent : colors.primary.icon
}
title={strings.useAccountPassword()}
style={{
width: "100%",
alignSelf: "flex-start",
justifyContent: "flex-start",
paddingHorizontal: 0,
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
}}
/>
</>
<Button
icon={accountPass ? "checkbox" : "box-empty"}
iconFamily="notesnook"
onPress={() => setAccountPass(!accountPass)}
iconSize={AppFontSize.sm}
fontFamily="MEDIUM"
fontSize={AppFontSize.xs}
type="plain"
iconColor={
accountPass
? [colors.primary.accent, colors.primary.accentForeground]
: colors.primary.icon
}
textStyle={{
color: colors.primary.paragraph
}}
title={strings.useAccountPassword()}
style={{
width: "100%",
alignSelf: "flex-start",
justifyContent: "flex-start",
paddingHorizontal: 0,
paddingVertical: 0
}}
/>
) : null}
</View>
<DialogButtons
onPressNegative={close}
onPressPositive={async () => {
if (mode === "create") {
if (!values.current.password || !values.current.confirmPassword) {
ToastManager.error(
new Error(strings.allFieldsRequired()),
undefined,
"local"
);
return;
}
if (values.current.password !== values.current.confirmPassword) {
ToastManager.error(
new Error(strings.mismatch(keyboardType)),
undefined,
"local"
);
return;
}
const password = values.current.password;
setAppLockVerificationCipher(password);
SettingsService.setProperty("appLockHasPasswordSecurity", true);
} else if (mode === "change") {
if (
!values.current.currentPassword ||
!values.current.password ||
!values.current.confirmPassword
) {
ToastManager.error(
new Error(strings.allFieldsRequired()),
undefined,
"local"
);
return;
}
if (values.current.password !== values.current.confirmPassword) {
ToastManager.error(
new Error(strings.mismatch(keyboardType)),
undefined,
"local"
);
return;
}
const isCurrentPasswordCorrect = await validateAppLockPassword(
values.current.currentPassword
);
if (!isCurrentPasswordCorrect) {
ToastManager.error(
new Error(strings.incorrect(keyboardType)),
undefined,
"local"
);
return;
}
const password = values.current.password;
await clearAppLockVerificationCipher();
SettingsService.setProperty("appLockHasPasswordSecurity", true);
await setAppLockVerificationCipher(password);
} else if (mode === "remove") {
if (!values.current.password) {
ToastManager.error(
new Error(strings.allFieldsRequired()),
undefined,
"local"
);
return;
}
const isCurrentPasswordCorrect = accountPass
? await db.user.verifyPassword(values.current.password)
: await validateAppLockPassword(values.current.password);
if (!isCurrentPasswordCorrect) {
ToastManager.error(
new Error(
accountPass
? strings.passwordIncorrect()
: strings.incorrect(keyboardType)
),
undefined,
"local"
);
return;
}
clearAppLockVerificationCipher();
SettingsService.setProperty("appLockHasPasswordSecurity", false);
if (
!(await BiometricService.isBiometryAvailable()) ||
SettingsService.getProperty("biometricsAuthEnabled") === false
) {
SettingsService.setProperty("appLockEnabled", false);
SettingsService.setPrivacyScreen(
SettingsService.getProperty("privacyScreen")
);
ToastManager.show({
message: strings.applockDisabled(),
type: "success"
});
}
}
SettingsService.setProperty(
"applockKeyboardType",
keyboardType === "password" ? "default" : "numeric"
);
close();
}}
positiveTitle={
mode === "remove"
? strings.remove()
: mode === "change"
? strings.change()
: strings.save()
}
negativeTitle={strings.cancel()}
positiveType="transparent"
loading={false}
doneText=""
/>
<View style={{ flexDirection: "row", gap: Spacing.LEVEL_2 }}>
<Button
title={strings.cancel()}
type="plain-outline"
onPress={close}
style={{ flex: 1, paddingVertical: Spacing.LEVEL_3 }}
/>
<Button
title={mode === "remove" ? strings.remove() : strings.save()}
type="accent"
onPress={onSubmit}
style={{ flex: 1, paddingVertical: Spacing.LEVEL_3 }}
/>
</View>
</View>
<Toast context="local" />
@@ -402,6 +311,9 @@ export const AppLockPassword = () => {
);
};
AppLockPassword.present = (mode: "create" | "change" | "remove") => {
eSendEvent(eOpenAppLockPasswordDialog, mode);
AppLockPassword.present = (
mode: "create" | "change" | "remove",
enableAppLock?: boolean
) => {
eSendEvent(eOpenAppLockPasswordDialog, mode, enableAppLock);
};

View File

@@ -35,6 +35,7 @@ import { Button } from "../../ui/button";
import { IconButton } from "../../ui/icon-button";
import { Notice } from "../../ui/notice";
import Paragraph from "../../ui/typography/paragraph";
import SettingsService from "../../../services/settings";
export default function AttachImage({
response,
@@ -201,6 +202,17 @@ AttachImage.present = (response: ImageType[], context?: string) => {
| undefined
>((resolve) => {
let resolved = false;
const imageCompressionSetting =
SettingsService.getProperty("imageCompression");
if (imageCompressionSetting !== "ask-every-time") {
resolve({
compress: imageCompressionSetting === "enabled" ? true : false
});
return;
}
presentSheet({
context: context,
component: (ref, close, update) => (

View File

@@ -45,6 +45,7 @@ import { IconButton } from "../../ui/icon-button";
import { ProgressBarComponent } from "../../ui/svg/lazy";
import Paragraph from "../../ui/typography/paragraph";
import ReactNativeBlobUtil from "react-native-blob-util";
import { useSettingStore } from "../../../stores/use-setting-store";
const WIN_WIDTH = Dimensions.get("window").width;
const WIN_HEIGHT = Dimensions.get("window").height;
@@ -286,6 +287,9 @@ const PDFPreview = () => {
color={colors.static.white}
name="open-in-new"
onPress={() => {
useSettingStore
.getState()
.setAppDidEnterBackgroundForAction(true);
FileViewer.open(pdfSource, {
showOpenWithDialog: true,
showAppsSuggestions: true

View File

@@ -17,28 +17,8 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect } from "react";
import { View } from "react-native";
import { eSendEvent, eSubscribeEvent } from "../../../services/event-manager";
import { AppFontSize } from "../../../utils/size";
import { Dialog } from "../../dialog";
import BaseDialog from "../../dialog/base-dialog";
import DialogContainer from "../../dialog/dialog-container";
import { Button } from "../../ui/button";
import { ProgressBarComponent } from "../../ui/svg/lazy";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
import { DefaultAppStyles } from "../../../utils/styles";
export type ProgressOptions = {
progress?: string;
cancelCallback?: () => void;
title?: string;
paragraph?: string;
fillBackground?: boolean;
canHideProgress?: boolean;
};
import { eSendEvent } from "../../../services/event-manager";
import { ProgressOptions } from "./progress";
export const PROGRESS_EVENTS = {
start: "startProgress",
@@ -46,146 +26,6 @@ export const PROGRESS_EVENTS = {
update: "updateProgress"
};
export default function Progress() {
const { colors } = useThemeColors();
const [progress, setProgress] = React.useState<string | undefined>();
const [visible, setVisible] = React.useState(false);
const cancelCallback = React.useRef<() => void>(undefined);
const [data, setData] = React.useState<{
title?: string;
paragraph?: string;
fillBackground?: boolean;
canHideProgress?: boolean;
}>();
useEffect(() => {
const events = [
eSubscribeEvent(PROGRESS_EVENTS.start, (options: ProgressOptions) => {
setProgress(options.progress);
cancelCallback.current = options.cancelCallback;
setData({
title: options.title,
paragraph: options.paragraph,
fillBackground: options.fillBackground,
canHideProgress: options.canHideProgress
});
setVisible(true);
}),
eSubscribeEvent(PROGRESS_EVENTS.end, () => {
setProgress(undefined);
setVisible(false);
setData(undefined);
cancelCallback.current?.();
cancelCallback.current = undefined;
}),
eSubscribeEvent(PROGRESS_EVENTS.update, (options: ProgressOptions) => {
setProgress(options.progress);
if (options.cancelCallback) {
cancelCallback.current = options.cancelCallback;
}
const data: ProgressOptions = {};
if (options.title) data.title = options.title;
if (options.paragraph) data.paragraph = options.paragraph;
if (options.fillBackground)
data.fillBackground = options.fillBackground;
setData((current) => {
return {
...current,
...data
};
});
})
];
return () => {
events.forEach((event) => event?.unsubscribe());
cancelCallback.current = undefined;
};
}, []);
return !visible ? null : (
<BaseDialog
background={data?.fillBackground ? colors.primary.background : undefined}
visible
>
<DialogContainer
style={{
paddingHorizontal: DefaultAppStyles.GAP,
paddingBottom: 10
}}
noBorder={data?.fillBackground ? true : false}
>
<Dialog context="local" />
<View
style={{
justifyContent: "center",
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP,
gap: 10,
paddingBottom: 20
}}
>
<Heading
style={{
textAlign: "center"
}}
color={colors.primary.paragraph}
size={AppFontSize.lg}
>
{data?.title}
</Heading>
<Paragraph
style={{
textAlign: "center"
}}
color={colors.secondary.paragraph}
>
{progress ? progress : data?.paragraph}
</Paragraph>
<View
style={{
flexDirection: "row",
width: 100,
paddingVertical: DefaultAppStyles.GAP_VERTICAL
}}
>
<ProgressBarComponent
height={5}
width={100}
animated={true}
useNativeDriver
indeterminate
indeterminateAnimationDuration={2000}
unfilledColor={colors.secondary.background}
color={colors.primary.accent}
borderWidth={0}
/>
</View>
{!data?.canHideProgress ? null : (
<Button
title={cancelCallback.current ? "Cancel" : "Hide"}
type="secondaryAccented"
onPress={() => {
if (cancelCallback.current) {
cancelCallback.current?.();
}
setVisible(false);
setProgress(undefined);
setData(undefined);
}}
width="100%"
/>
)}
</View>
</DialogContainer>
</BaseDialog>
);
}
export function startProgress(options: ProgressOptions) {
eSendEvent(PROGRESS_EVENTS.start, options);
}

View File

@@ -0,0 +1,210 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect } from "react";
import { View } from "react-native";
import { PROGRESS_EVENTS } from ".";
import { Radius, Spacing } from "../../../common/design/spacing";
import { eSubscribeEvent } from "../../../services/event-manager";
import { AppFontSize } from "../../../utils/size";
import { Dialog } from "../../dialog";
import BaseDialog from "../../dialog/base-dialog";
import DialogContainer from "../../dialog/dialog-container";
import AppIcon from "../../ui/AppIcon";
import { Button } from "../../ui/button";
import { ProgressBarComponent } from "../../ui/svg/lazy";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
export type ProgressOptions = {
progress?: string;
cancelCallback?: () => void;
title?: string;
paragraph?: string;
fillBackground?: boolean;
canHideProgress?: boolean;
icon?: string;
};
export default function Progress() {
const { colors } = useThemeColors();
const [progress, setProgress] = React.useState<string | undefined>();
const [visible, setVisible] = React.useState(false);
const cancelCallback = React.useRef<() => void>(undefined);
const [data, setData] = React.useState<{
title?: string;
paragraph?: string;
fillBackground?: boolean;
canHideProgress?: boolean;
icon?: string;
}>();
useEffect(() => {
const events = [
eSubscribeEvent(PROGRESS_EVENTS.start, (options: ProgressOptions) => {
setProgress(options.progress);
cancelCallback.current = options.cancelCallback;
setData({
...options
});
setVisible(true);
}),
eSubscribeEvent(PROGRESS_EVENTS.end, () => {
setProgress(undefined);
setVisible(false);
setData(undefined);
cancelCallback.current?.();
cancelCallback.current = undefined;
}),
eSubscribeEvent(PROGRESS_EVENTS.update, (options: ProgressOptions) => {
setProgress(options.progress);
if (options.cancelCallback) {
cancelCallback.current = options.cancelCallback;
}
const data: ProgressOptions = {};
if (options.title) data.title = options.title;
if (options.paragraph) data.paragraph = options.paragraph;
if (options.fillBackground)
data.fillBackground = options.fillBackground;
if (options.icon) {
data.icon = options.icon;
}
setData((current) => {
return {
...current,
...data
};
});
})
];
return () => {
events.forEach((event) => event?.unsubscribe());
cancelCallback.current = undefined;
};
}, []);
return !visible ? null : (
<BaseDialog
background={data?.fillBackground ? colors.primary.background : undefined}
visible
>
<DialogContainer
style={{
padding: Spacing.LEVEL_4
}}
noBorder={data?.fillBackground ? true : false}
>
<Dialog context="local" />
<View
style={{
justifyContent: "center",
alignItems: "center",
gap: Spacing.LEVEL_4,
paddingTop: Spacing.LEVEL_1
}}
>
{data?.icon ? (
<View
style={{
width: 40,
height: 40,
borderRadius: Radius.XS,
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.secondary.background
}}
>
<AppIcon
name={data?.icon}
iconFamily="notesnook"
size={20}
color={colors.primary.icon}
/>
</View>
) : null}
<View
style={{
gap: Spacing.LEVEL_1
}}
>
<Heading
style={{
textAlign: "center"
}}
color={colors.primary.heading}
size={AppFontSize.lg}
>
{data?.title}
</Heading>
<Paragraph
style={{
textAlign: "center"
}}
color={colors.secondary.paragraph}
>
{progress ? progress : data?.paragraph}
</Paragraph>
<View
style={{
flexDirection: "row",
width: "100%"
}}
>
<ProgressBarComponent
height={8}
width={null}
style={{
flex: 1
}}
animated={true}
useNativeDriver
indeterminate
indeterminateAnimationDuration={2000}
unfilledColor={colors.secondary.background}
color={colors.primary.accent}
borderWidth={0}
/>
</View>
</View>
{!data?.canHideProgress ? null : (
<Button
title={cancelCallback.current ? "Cancel" : "Hide"}
type="accent"
onPress={() => {
if (cancelCallback.current) {
cancelCallback.current?.();
}
setVisible(false);
setProgress(undefined);
setData(undefined);
}}
width={160}
/>
)}
</View>
</DialogContainer>
</BaseDialog>
);
}

View File

@@ -51,8 +51,10 @@ import DialogButtons from "../../dialog/dialog-buttons";
import DialogHeader from "../../dialog/dialog-header";
import { Toast } from "../../toast";
import { Button } from "../../ui/button";
import Input from "../../ui/input";
import Seperator from "../../ui/seperator";
import FormInput, {
createFormRef,
validators
} from "../../ui/input/form-input";
import Paragraph from "../../ui/typography/paragraph";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../../utils/styles";
@@ -63,6 +65,10 @@ import {
VAULT_ERRORS
} from "@notesnook/core";
import { useThemeColors } from "@notesnook/theme";
import { Radius, Spacing } from "../../../common/design/spacing";
import { PASSWORD_PLACEHOLDER } from "../../../utils/constants";
import { AppFontSize } from "../../../utils/size";
import { useSettingStore } from "../../../stores/use-setting-store";
export const VaultDialog: React.FC = () => {
const { colors } = useThemeColors();
@@ -70,8 +76,6 @@ export const VaultDialog: React.FC = () => {
// UI State
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false);
const [wrongPassword, setWrongPassword] = useState(false);
const [passwordsDontMatch, setPasswordsDontMatch] = useState(false);
const [deleteAll, setDeleteAll] = useState(false);
const [biometricUnlock, setBiometricUnlock] = useState(false);
const [isBiometryAvailable, setIsBiometryAvailable] = useState(false);
@@ -85,7 +89,7 @@ export const VaultDialog: React.FC = () => {
const paragraphRef = useRef<string | null>(null);
const buttonTitleRef = useRef<string | null>(null);
const positiveButtonTypeRef = useRef<"errorShade" | "transparent" | "accent">(
"transparent"
"accent"
);
const customActionTitleRef = useRef<string | null>(null);
const customActionParagraphRef = useRef<string | null>(null);
@@ -100,15 +104,20 @@ export const VaultDialog: React.FC = () => {
| undefined
>(undefined);
// Form ref
const formRef = useRef(
createFormRef({
password: "",
confirmPassword: "",
newPassword: ""
})
);
// Input refs
const passInputRef = useRef<TextInput>(null);
const confirmPassRef = useRef<TextInput>(null);
const changePassInputRef = useRef<TextInput>(null);
// Password refs
const passwordRef = useRef<string | null>(null);
const confirmPasswordRef = useRef<string | null>(null);
const newPasswordRef = useRef<string | null>(null);
const newPassInputRef = useRef<TextInput>(null);
const [icon, setIcon] = useState<string>();
const close = useCallback(() => {
if (loading) {
@@ -123,10 +132,11 @@ export const VaultDialog: React.FC = () => {
Navigation.queueRoutesForUpdate();
// Reset password refs
passwordRef.current = null;
confirmPasswordRef.current = null;
newPasswordRef.current = null;
// Reset form values and errors
formRef.current.setValue("password", "");
formRef.current.setValue("confirmPassword", "");
formRef.current.setValue("newPassword", "");
formRef.current.clearErrors();
// Reset refs
requestTypeRef.current = null;
@@ -135,7 +145,7 @@ export const VaultDialog: React.FC = () => {
descriptionRef.current = null;
paragraphRef.current = null;
buttonTitleRef.current = null;
positiveButtonTypeRef.current = "transparent";
positiveButtonTypeRef.current = "accent";
customActionTitleRef.current = null;
customActionParagraphRef.current = null;
noteLockedRef.current = false;
@@ -144,8 +154,6 @@ export const VaultDialog: React.FC = () => {
// Reset UI state
setVisible(false);
setLoading(false);
setWrongPassword(false);
setPasswordsDontMatch(false);
setDeleteAll(false);
setBiometricUnlock(false);
setIsBiometryAvailable(false);
@@ -155,9 +163,10 @@ export const VaultDialog: React.FC = () => {
const deleteVault = useCallback(async () => {
setLoading(true);
try {
const { password } = formRef.current.getValues();
let verified = true;
if (await db.user.getUser()) {
verified = await db.user.verifyPassword(passwordRef.current || "");
verified = await db.user.verifyPassword(password);
}
if (verified) {
let noteIds: string[] = [];
@@ -175,6 +184,7 @@ export const VaultDialog: React.FC = () => {
noteIds = relations.map((item) => item.toId);
}
await db.vault.delete(deleteAll);
await BiometricService.resetCredentials();
if (deleteAll) {
noteIds.forEach((id) => {
@@ -195,11 +205,7 @@ export const VaultDialog: React.FC = () => {
}, 100);
} else {
setLoading(false);
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
formRef.current.setError("password", strings.passwordIncorrect());
}
} catch (e) {
console.error(e);
@@ -209,11 +215,12 @@ export const VaultDialog: React.FC = () => {
const clearVault = useCallback(async () => {
setLoading(true);
try {
const { password } = formRef.current.getValues();
const vault = await db.vaults.default();
const relations = await db.relations.from(vault!, "note").get();
const noteIds = relations.map((item) => item.toId);
await db.vault.clear(passwordRef.current || "");
await db.vault.clear(password);
noteIds.forEach((id) => {
eSendEvent(
@@ -233,11 +240,7 @@ export const VaultDialog: React.FC = () => {
type: "success"
});
} catch (e) {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
formRef.current.setError("password", strings.passwordIncorrect());
}
setLoading(false);
}, [close]);
@@ -257,12 +260,7 @@ export const VaultDialog: React.FC = () => {
});
close();
} catch (e) {
close();
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
formRef.current.setError("password", strings.passwordIncorrect());
setLoading(false);
}
},
@@ -270,24 +268,14 @@ export const VaultDialog: React.FC = () => {
);
const takeErrorAction = useCallback(() => {
setWrongPassword(true);
formRef.current.setError("password", strings.passwordIncorrect());
setVisible(true);
setTimeout(() => {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
}, 500);
}, []);
const lockNote = useCallback(async () => {
if (!passwordRef.current || passwordRef.current.trim() === "") {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
const { password } = formRef.current.getValues();
if (!password || password.trim() === "") {
formRef.current.setError("password", strings.passwordIncorrect());
return;
} else {
await db.vault.add(noteRef.current!.id);
@@ -305,8 +293,9 @@ export const VaultDialog: React.FC = () => {
}, [close]);
const permanantUnlock = useCallback(() => {
const { password } = formRef.current.getValues();
db.vault
.remove(noteRef.current!.id, passwordRef.current || "")
.remove(noteRef.current!.id, password)
.then(async () => {
ToastManager.show({
heading: strings.noteUnlocked(),
@@ -315,7 +304,7 @@ export const VaultDialog: React.FC = () => {
});
eSendEvent(eUpdateNoteInEditor, noteRef.current, true);
if (biometricUnlock && !isBiometryEnrolled) {
await enrollFingerprint(passwordRef.current || "");
await enrollFingerprint(password);
}
close();
})
@@ -362,6 +351,7 @@ export const VaultDialog: React.FC = () => {
async (note: Note & { content?: NoteContent<false> }) => {
close();
try {
useSettingStore.getState().setAppDidEnterBackgroundForAction(true);
await Share.open({
title: note.title,
failOnCancel: false,
@@ -375,8 +365,9 @@ export const VaultDialog: React.FC = () => {
);
const deleteNote = useCallback(async () => {
const { password } = formRef.current.getValues();
try {
await db.vault.remove(noteRef.current!.id, passwordRef.current || "");
await db.vault.remove(noteRef.current!.id, password);
await deleteItems("note", [noteRef.current!.id]);
close();
} catch (e) {
@@ -385,16 +376,14 @@ export const VaultDialog: React.FC = () => {
}, [close, takeErrorAction]);
const openNote = useCallback(async () => {
const { password } = formRef.current.getValues();
try {
if (!passwordRef.current) throw new Error("Invalid password");
if (!password) throw new Error("Invalid password");
const note = await db.vault.open(
noteRef.current!.id,
passwordRef.current
);
const note = await db.vault.open(noteRef.current!.id, password);
if (!note) throw new Error("Failed to unlock note.");
if (biometricUnlock && !isBiometryEnrolled) {
await enrollFingerprint(passwordRef.current || "");
await enrollFingerprint(password);
}
const requestType = requestTypeRef.current;
@@ -411,7 +400,6 @@ export const VaultDialog: React.FC = () => {
requestType === VaultRequestType.CustomAction &&
onUnlockRef.current
) {
const password = passwordRef.current;
const unlock = onUnlockRef.current;
close();
await sleep(500);
@@ -433,12 +421,9 @@ export const VaultDialog: React.FC = () => {
]);
const unlockNote = useCallback(async () => {
if (!passwordRef.current || passwordRef.current.trim() === "") {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
const { password } = formRef.current.getValues();
if (!password || password.trim() === "") {
formRef.current.setError("password", strings.passwordIncorrect());
return;
}
if (requestTypeRef.current === VaultRequestType.PermanentUnlock) {
@@ -449,10 +434,11 @@ export const VaultDialog: React.FC = () => {
}, [permanantUnlock, openNote]);
const createVault = useCallback(async () => {
await db.vault.create(passwordRef.current || "");
const { password } = formRef.current.getValues();
await db.vault.create(password);
if (biometricUnlock) {
await enrollFingerprint(passwordRef.current || "");
await enrollFingerprint(password);
}
if (noteRef.current?.id) {
await db.vault.add(noteRef.current.id);
@@ -504,36 +490,23 @@ export const VaultDialog: React.FC = () => {
if (loading) return;
if (!passwordRef.current) {
ToastManager.show({
heading: strings.passwordNotEntered(),
type: "error",
context: "local"
});
return;
}
if (!formRef.current.validate()) return;
const { password, newPassword } = formRef.current.getValues();
if (requestType === VaultRequestType.CreateVault) {
if (passwordRef.current !== confirmPasswordRef.current) {
ToastManager.show({
heading: strings.passwordNotMatched(),
type: "error",
context: "local"
});
setPasswordsDontMatch(true);
return;
}
createVault();
} else if (requestType === VaultRequestType.ChangePassword) {
setLoading(true);
db.vault
.changePassword(passwordRef.current, newPasswordRef.current || "")
.then(() => {
.changePassword(password, newPassword)
.then(async () => {
setLoading(false);
if (biometricUnlock) {
enrollFingerprint(newPasswordRef.current || "");
enrollFingerprint(newPassword);
} else {
await BiometricService.resetCredentials();
}
ToastManager.show({
heading: strings.passwordUpdated(),
@@ -545,37 +518,23 @@ export const VaultDialog: React.FC = () => {
.catch((e) => {
setLoading(false);
if (e.message === VAULT_ERRORS.wrongPassword) {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
formRef.current.setError("password", strings.passwordIncorrect());
} else {
ToastManager.error(e);
console.error(e);
}
});
} else if (requestType === VaultRequestType.LockNote) {
if (!passwordRef.current || passwordRef.current.trim() === "") {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
setWrongPassword(true);
return;
}
db.vault
.unlock(passwordRef.current)
.unlock(password)
.then(async (unlocked) => {
if (unlocked) {
setWrongPassword(false);
await lockNote();
} else {
takeErrorAction();
formRef.current.setError("password", strings.passwordIncorrect());
}
})
.catch((e) => {
takeErrorAction();
formRef.current.setError("password", strings.passwordIncorrect());
});
} else if (
requestType === VaultRequestType.UnlockNote ||
@@ -586,22 +545,13 @@ export const VaultDialog: React.FC = () => {
requestType === VaultRequestType.DeleteNote ||
requestType === VaultRequestType.CustomAction
) {
if (!passwordRef.current || passwordRef.current.trim() === "") {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
setWrongPassword(true);
return;
}
if (noteLockedRef.current) {
await unlockNote();
} else {
console.log("Error: Note should be locked for this operation");
}
} else if (requestType === VaultRequestType.EnableFingerprint) {
enrollFingerprint(passwordRef.current);
enrollFingerprint(password);
} else if (requestType === VaultRequestType.ClearVault) {
await clearVault();
} else if (requestType === VaultRequestType.DeleteVault) {
@@ -616,7 +566,6 @@ export const VaultDialog: React.FC = () => {
enrollFingerprint,
unlockNote,
lockNote,
takeErrorAction,
clearVault,
deleteVault
]);
@@ -632,7 +581,7 @@ export const VaultDialog: React.FC = () => {
if (!credentials) throw new Error("Failed to get user credentials");
if (credentials?.password) {
passwordRef.current = credentials.password;
formRef.current.setValue("password", credentials.password);
onPress();
} else {
eSendEvent(eCloseActionSheet);
@@ -679,8 +628,7 @@ export const VaultDialog: React.FC = () => {
setIsBiometryAvailable(available);
setIsBiometryEnrolled(fingerprint);
setBiometricUnlock(fingerprint);
setWrongPassword(false);
setPasswordsDontMatch(false);
setIcon(data.icon);
setDeleteAll(false);
setLoading(false);
@@ -744,9 +692,9 @@ export const VaultDialog: React.FC = () => {
style={{
...getElevationStyle(5),
width: DDS.isTab ? 350 : "85%",
borderRadius: 10,
borderRadius: Radius.MD,
backgroundColor: colors.primary.background,
paddingTop: 12,
paddingVertical: Spacing.LEVEL_4,
overflow: "hidden"
}}
>
@@ -755,14 +703,21 @@ export const VaultDialog: React.FC = () => {
paragraph={
paragraphRef.current || customActionParagraphRef.current || ""
}
icon="shield"
padding={12}
style={{
paddingHorizontal: Spacing.LEVEL_3
}}
centered={!!icon}
icon={icon}
iconType="error"
iconFamily="notesnook"
/>
<Seperator half />
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP
paddingHorizontal: Spacing.LEVEL_3,
gap: isDeleteVault ? 0 : Spacing.LEVEL_4,
marginTop: Spacing.LEVEL_4,
marginBottom: Spacing.LEVEL_1
}}
>
{(isChangePassword ||
@@ -772,41 +727,33 @@ export const VaultDialog: React.FC = () => {
isCustomAction) &&
!isRevokeFingerprint ? (
<>
<Input
<FormInput
name="password"
formRef={formRef}
label={
isChangePassword
? strings.currentPassword()
: strings.password()
}
fwdRef={passInputRef}
editable={!loading}
autoCapitalize="none"
testID={notesnook.ids.dialogs.vault.pwd}
onChangeText={(value) => {
passwordRef.current = value;
}}
marginBottom={
!biometricUnlock ||
!isBiometryEnrolled ||
isCreateVault ||
isChangePassword ||
isCustomAction
? 0
: 10
}
onSubmit={() => {
autoComplete="password"
onSubmitEditing={() => {
if (isChangePassword) {
confirmPassRef.current?.focus();
newPassInputRef.current?.focus();
} else {
onPress();
}
}}
autoComplete="password"
returnKeyLabel={
isChangePassword ? strings.next() : titleRef.current
}
returnKeyType={isChangePassword ? "next" : "done"}
secureTextEntry
placeholder={
isChangePassword
? strings.currentPassword()
: strings.password()
}
placeholder={PASSWORD_PLACEHOLDER}
validators={[validators.required(strings.passwordRequired())]}
/>
{!biometricUnlock ||
@@ -832,87 +779,90 @@ export const VaultDialog: React.FC = () => {
{isDeleteVault && (
<Button
onPress={() => setDeleteAll(!deleteAll)}
icon={
deleteAll
? "check-circle-outline"
: "checkbox-blank-circle-outline"
}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
icon={deleteAll ? "checkbox" : "box-empty"}
iconFamily="notesnook"
fontFamily="MEDIUM"
iconSize={14}
fontSize={AppFontSize.sm}
width="100%"
style={{
justifyContent: "flex-start",
paddingHorizontal: 0
}}
title={strings.deleteAllNotes()}
type="errorShade"
type="transparent"
iconColor={colors.error.accent}
textStyle={{
color: colors.error.accent
}}
/>
)}
{isChangePassword ? (
<>
<Seperator half />
<Input
fwdRef={confirmPassRef}
<FormInput
name="newPassword"
label={strings.newPassword()}
formRef={formRef}
fwdRef={newPassInputRef}
editable={!loading}
testID={notesnook.ids.dialogs.vault.changePwd}
autoCapitalize="none"
onChangeText={(value) => {
newPasswordRef.current = value;
}}
autoComplete="password"
onSubmit={() => {
onSubmitEditing={() => {
onPress();
}}
returnKeyLabel="Change"
returnKeyType="done"
secureTextEntry
placeholder={strings.newPassword()}
placeholder={PASSWORD_PLACEHOLDER}
validators={[validators.required(strings.passwordRequired())]}
/>
</>
) : null}
{isCreateVault ? (
<View>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passInputRef}
autoCapitalize="none"
testID={notesnook.ids.dialogs.vault.pwd}
onChangeText={(value) => {
passwordRef.current = value;
}}
autoComplete="password"
returnKeyLabel={strings.next()}
returnKeyType="next"
secureTextEntry
onSubmit={() => {
onSubmitEditing={() => {
confirmPassRef.current?.focus();
}}
placeholder={strings.password()}
placeholder={PASSWORD_PLACEHOLDER}
validators={[validators.required(strings.passwordRequired())]}
/>
<Input
<FormInput
name="confirmPassword"
label={strings.confirmPassword()}
formRef={formRef}
fwdRef={confirmPassRef}
autoCapitalize="none"
testID={notesnook.ids.dialogs.vault.pwdAlt}
secureTextEntry
validationType="confirmPassword"
customValidator={() => passwordRef.current || ""}
errorMessage="Passwords do not match."
onErrorCheck={() => null}
marginBottom={0}
autoComplete="password"
returnKeyLabel="Create"
returnKeyType="done"
onChangeText={(value) => {
confirmPasswordRef.current = value;
if (value !== passwordRef.current) {
setPasswordsDontMatch(true);
} else {
setPasswordsDontMatch(false);
}
}}
onSubmit={() => {
marginBottom={0}
onSubmitEditing={() => {
onPress();
}}
placeholder={strings.confirmPassword()}
placeholder={PASSWORD_PLACEHOLDER}
validators={[
validators.required(strings.confirmPasswordRequired()),
validators.matchField(
"password",
strings.passwordNotMatched()
)
]}
/>
</View>
) : null}

View File

@@ -17,25 +17,24 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useCallback, useEffect, useState } from "react";
import { View } from "react-native";
import { View, ViewStyle } from "react-native";
import { notesnook } from "../../../e2e/test.ids";
import { Radius, Spacing } from "../../common/design/spacing";
import {
eSubscribeEvent,
eUnSubscribeEvent
} from "../../services/event-manager";
import Navigation from "../../services/navigation";
import { RouteName } from "../../stores/use-navigation-store";
import { useSelectionStore } from "../../stores/use-selection-store";
import { useSettingStore } from "../../stores/use-setting-store";
import { eScrollEvent } from "../../utils/events";
import { AppFontSize } from "../../utils/size";
import { fluidTabsRef } from "../../utils/global-refs";
import { DefaultAppStyles } from "../../utils/styles";
import { IconButtonProps } from "../ui/icon-button";
import { Pressable } from "../ui/pressable";
import { IconButton, IconButtonProps } from "../ui/icon-button";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { LeftMenus } from "./left-menus";
import { RightMenus } from "./right-menus";
export const Header = ({
renderedInRoute,
@@ -45,7 +44,8 @@ export const Header = ({
canGoBack,
hasSearch,
onSearch,
rightButton
rightButton,
style
}: {
onLeftMenuButtonPress?: () => void;
renderedInRoute?: RouteName;
@@ -56,6 +56,7 @@ export const Header = ({
hasSearch?: boolean;
onSearch?: () => void;
rightButton?: IconButtonProps;
style?: ViewStyle;
}) => {
const { colors } = useThemeColors();
const [borderHidden, setBorderHidden] = useState(true);
@@ -64,6 +65,9 @@ export const Header = ({
state.selectionMode
]);
const deviceMode = useSettingStore((state) => state.deviceMode);
const isTablet = deviceMode === "tablet";
const onScroll = useCallback(
(data: { x: number; y: number; id?: string; route: string }) => {
if (data.route !== renderedInRoute || data.id !== id) return;
@@ -85,34 +89,61 @@ export const Header = ({
};
}, [borderHidden, onScroll]);
const HeaderWrapper = hasSearch ? Pressable : View;
const _onLeftButtonPress = () => {
if (onLeftMenuButtonPress) return onLeftMenuButtonPress();
if (!canGoBack) {
if (fluidTabsRef.current?.isDrawerOpen()) {
Navigation.closeDrawer();
} else {
Navigation.openDrawer();
}
return;
}
Navigation.goBack();
};
return (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP
paddingHorizontal: DefaultAppStyles.GAP,
marginBottom: Spacing.LEVEL_3
}}
>
<HeaderWrapper
style={{
flexDirection: "row",
justifyContent: "space-between",
borderRadius: 10,
paddingVertical: 3,
borderWidth: hasSearch ? 1 : 0,
borderColor: colors.primary.border,
paddingHorizontal: !hasSearch ? 0 : DefaultAppStyles.GAP_SMALL,
alignItems: "center"
}}
<View
style={[
{
flexDirection: "row",
justifyContent: "space-between",
borderRadius: Radius.S,
paddingHorizontal: Spacing.LEVEL_2,
paddingVertical: Spacing.LEVEL_3,
backgroundColor: colors.secondary.background,
alignItems: "center"
},
style
]}
testID="search-header"
onPress={() => {
onSearch?.();
}}
>
<LeftMenus
canGoBack={canGoBack}
onLeftButtonPress={onLeftMenuButtonPress}
/>
{isTablet && !canGoBack ? null : (
<IconButton
testID={notesnook.ids.default.header.buttons.left}
left={40}
top={40}
onPress={_onLeftButtonPress}
onLongPress={() => {
Navigation.popToTop();
}}
style={{
width: 20,
height: 20
}}
size={20}
name={canGoBack ? "arrow-back" : "menu"}
iconFamily="notesnook"
color={colors.primary.icon}
/>
)}
{!title ? (
<View
@@ -123,18 +154,39 @@ export const Header = ({
borderRadius: 100
}}
/>
) : hasSearch ? (
<Paragraph>
{selectionMode
? `${selectedItemsList.length} selected`
: strings.searchInRoute(title)}
</Paragraph>
) : (
<Heading size={AppFontSize.lg}>{title}</Heading>
<Heading fontSize="XL">
{selectionMode ? `${selectedItemsList.length} selected` : title}
</Heading>
)}
<RightMenus rightButton={rightButton} />
</HeaderWrapper>
<View
style={{
flexDirection: "row",
alignItems: "center"
}}
>
{rightButton ? (
<IconButton {...rightButton} color={colors.primary.icon} />
) : null}
{hasSearch ? (
<IconButton
color={colors.primary.icon}
size={20}
onPress={() => {
onSearch?.();
}}
style={{
width: 20,
height: 20
}}
iconFamily="notesnook"
name="search"
/>
) : null}
</View>
</View>
</View>
);
};

View File

@@ -1,66 +0,0 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { notesnook } from "../../../e2e/test.ids";
import Navigation from "../../services/navigation";
import { useSettingStore } from "../../stores/use-setting-store";
import { fluidTabsRef } from "../../utils/global-refs";
import { IconButton } from "../ui/icon-button";
export const LeftMenus = ({
canGoBack,
onLeftButtonPress
}: {
canGoBack?: boolean;
onLeftButtonPress?: () => void;
}) => {
const { colors } = useThemeColors();
const deviceMode = useSettingStore((state) => state.deviceMode);
const isTablet = deviceMode === "tablet";
const _onLeftButtonPress = () => {
if (onLeftButtonPress) return onLeftButtonPress();
if (!canGoBack) {
if (fluidTabsRef.current?.isDrawerOpen()) {
Navigation.closeDrawer();
} else {
Navigation.openDrawer();
}
return;
}
Navigation.goBack();
};
return isTablet && !canGoBack ? null : (
<IconButton
testID={notesnook.ids.default.header.buttons.left}
left={40}
top={40}
onPress={_onLeftButtonPress}
onLongPress={() => {
Navigation.popToTop();
}}
name={canGoBack ? "arrow-left" : "menu"}
color={colors.primary.icon}
/>
);
};

View File

@@ -0,0 +1,314 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import React from "react";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withSpring,
withTiming
} from "react-native-reanimated";
import Svg, {
Circle,
ClipPath,
Defs,
G,
Mask,
Path,
Rect
} from "react-native-svg";
import { useThemeColors } from "@notesnook/theme";
const SPRING_CONFIG = {
damping: 14,
stiffness: 120,
mass: 0.8
};
// Each icon floats independently with a different phase/speed
function useFloatAnim(delay: number, range = 6) {
const y = useSharedValue(0);
React.useEffect(() => {
const start = () => {
y.value = withSequence(
withTiming(0, { duration: delay, easing: Easing.linear }),
withRepeat(
withSequence(
withTiming(-range, {
duration: 1600,
easing: Easing.inOut(Easing.sin)
}),
withTiming(range, {
duration: 1600,
easing: Easing.inOut(Easing.sin)
})
),
-1, // infinite
true // reverse
)
);
};
start();
}, []);
return y;
}
export const IntroIllustration = () => {
const { isDark } = useThemeColors();
// Gesture-driven tilt
const tiltX = useSharedValue(0); // rotateX: finger up/down
const tiltY = useSharedValue(0); // rotateY: finger left/right
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
// Independent float offsets for each corner icon
const floatTR = useFloatAnim(0, 2); // top-right lock
const floatBR = useFloatAnim(500, 4); // bottom-right eye-off
const floatTL = useFloatAnim(260, 3); // top-left share
const floatBL = useFloatAnim(780, 2); // bottom-left document
const pan = Gesture.Pan()
.onUpdate((e) => {
// Clamp tilt to ±15 degrees and translate ±12 pts
tiltY.value = (e.translationX / 12) * 1;
tiltX.value = -(e.translationY / 12) * 1;
translateX.value = e.translationX * 0.08;
translateY.value = e.translationY * 0.08;
})
.onEnd(() => {
tiltX.value = withSpring(0, SPRING_CONFIG);
tiltY.value = withSpring(0, SPRING_CONFIG);
translateX.value = withSpring(0, SPRING_CONFIG);
translateY.value = withSpring(0, SPRING_CONFIG);
});
const containerStyle = useAnimatedStyle(() => ({
transform: [
{ perspective: 600 },
{ rotateX: `${tiltX.value}deg` },
{ rotateY: `${tiltY.value}deg` },
{ translateX: translateX.value },
{ translateY: translateY.value }
]
}));
// Derived animated styles for each floating icon
const styleTR = useAnimatedStyle(() => ({
transform: [{ translateY: floatTR.value }]
}));
const styleBR = useAnimatedStyle(() => ({
transform: [{ translateY: floatBR.value }]
}));
const styleTL = useAnimatedStyle(() => ({
transform: [{ translateY: floatTL.value }]
}));
const styleBL = useAnimatedStyle(() => ({
transform: [{ translateY: floatBL.value }]
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={containerStyle}>
<Svg width={243} height={210} viewBox="0 0 243 210" fill="none">
{/* ── Concentric background circles (static) ── */}
<Circle
cx={116}
cy={105}
r={104.75}
stroke={isDark ? "#1F2722" : "#E8F4ED"}
strokeWidth={0.5}
/>
<Circle
cx={116.5}
cy={105.5}
r={95.25}
fill="#008836"
fillOpacity={0.05}
stroke={isDark ? "#233C2D" : "#E8F0EC"}
strokeWidth={0.5}
/>
<Circle
cx={116.5}
cy={105.5}
r={82.25}
fill="#008836"
fillOpacity={0.04}
stroke={isDark ? "#233C2D" : "#E3F1E8"}
strokeWidth={0.5}
/>
<Circle
cx={116.5}
cy={105.5}
r={62.25}
fill="#008836"
fillOpacity={0.06}
stroke={isDark ? "#233C2D" : "#D3E8DB"}
strokeWidth={0.5}
/>
<Circle cx={116.5} cy={105.5} r={44.5} fill="#008836" />
{/* ── Defs ── */}
<Defs>
<ClipPath id="clip0">
<Rect width={45} height={45} x={94.5} y={83} />
</ClipPath>
<ClipPath id="clip1">
<Rect width={21.5} height={21.5} x={202.75} y={176.75} />
</ClipPath>
<Mask
id="mask0"
maskUnits="userSpaceOnUse"
x={91}
y={80}
width={52}
height={51}
>
<Path
d="M132.504 80.055H101.496C96.007 80.055 91.5574 84.5046 91.5574 89.9935V121.002C91.5574 126.491 96.007 130.94 101.496 130.94H132.504C137.993 130.94 142.443 126.491 142.443 121.002V89.9935C142.443 84.5046 137.993 80.055 132.504 80.055Z"
fill="white"
/>
</Mask>
</Defs>
{/* ── Centre logo (static) ── */}
<G clipPath="url(#clip0)">
<G mask="url(#mask0)">
<Path
d="M127.584 113.991C126.726 116.496 125.008 118.616 122.734 119.973C120.461 121.33 117.78 121.836 115.168 121.401C112.556 120.967 110.183 119.62 108.471 117.6C106.759 115.58 105.82 113.018 105.819 110.371V104.892L109.751 106.535V110.368C109.751 111.401 109.971 112.422 110.398 113.362C110.825 114.302 111.448 115.14 112.226 115.819C112.373 115.948 112.527 116.073 112.685 116.19C113.845 117.054 115.239 117.549 116.685 117.611C116.734 117.611 116.781 117.615 116.83 117.616C116.879 117.617 116.942 117.616 116.998 117.616H117.166C117.221 117.616 117.262 117.616 117.311 117.611C118.756 117.549 120.149 117.054 121.31 116.191C121.467 116.074 121.621 115.949 121.769 115.821C122.79 114.927 123.541 113.765 123.934 112.467L127.584 113.991ZM128.181 100.628V110.371C128.181 110.497 128.181 110.623 128.173 110.749L124.249 109.108V100.628C124.249 98.7551 123.523 96.9554 122.225 95.6055C120.928 94.2557 119.158 93.4604 117.287 93.3862C115.415 93.3121 113.588 93.9649 112.188 95.2078C110.787 96.4508 109.922 98.1875 109.773 100.054C109.759 100.243 109.751 100.435 109.751 100.628V103.048L105.819 101.405V89.4469H117C119.965 89.4469 122.809 90.6249 124.906 92.7217C127.003 94.8185 128.181 97.6624 128.181 100.628Z"
fill="white"
/>
</G>
</G>
</Svg>
{/* ── Floating corner icons rendered as absolute Animated.Views over the SVG ── */}
{/* Top-left — share */}
<Animated.View
style={[{ position: "absolute", top: 0, left: -10 }, styleTL]}
>
<Svg width={47} height={47} viewBox="0 0 47 47" fill="none">
<Rect
x={0.25}
y={0.25}
width={46.5}
height={46.5}
rx={7.75}
fill={isDark ? "#1A1D1B" : "#f7f7f7"}
stroke={isDark ? "#233C2D" : "#D3E8DB"}
strokeWidth={0.5}
/>
<Rect
width={22}
height={22}
x={13}
y={12}
fill={isDark ? "#1A1D1B" : "#f7f7f7"}
/>
<Path
d="M28.125 25.75C27.6663 25.7499 27.2124 25.8417 26.7899 26.0202C26.3674 26.1987 25.9851 26.4601 25.6654 26.789L21.7037 24.2427C22.0153 23.4435 22.0153 22.5565 21.7037 21.7573L25.6654 19.211C26.2605 19.8206 27.0607 20.1875 27.911 20.2406C28.7612 20.2937 29.6008 20.0292 30.2671 19.4984C30.9334 18.9675 31.3788 18.2082 31.5171 17.3677C31.6554 16.5271 31.4766 15.6651 31.0154 14.9488C30.5542 14.2326 29.8435 13.7131 29.0211 13.4911C28.1987 13.269 27.3231 13.3603 26.5641 13.7472C25.8051 14.134 25.2169 14.7889 24.9133 15.5849C24.6098 16.3808 24.6126 17.2612 24.9212 18.0552L20.9595 20.6015C20.4824 20.1117 19.8701 19.7753 19.2009 19.6353C18.5317 19.4953 17.8359 19.5581 17.2025 19.8156C16.5691 20.0731 16.0269 20.5136 15.6452 21.0808C15.2635 21.6481 15.0596 22.3163 15.0596 23C15.0596 23.6837 15.2635 24.3519 15.6452 24.9192C16.0269 25.4864 16.5691 25.9269 17.2025 26.1844C17.8359 26.4419 18.5317 26.5047 19.2009 26.3647C19.8701 26.2247 20.4824 25.8883 20.9595 25.3985L24.9212 27.9448C24.6558 28.6294 24.6165 29.3809 24.8091 30.0893C25.0018 30.7978 25.4161 31.4259 25.9916 31.8819C26.567 32.3378 27.2733 32.5975 28.007 32.6229C28.7408 32.6484 29.4634 32.4383 30.069 32.0233C30.6747 31.6084 31.1316 31.0105 31.3729 30.3171C31.6141 29.6237 31.627 28.8712 31.4097 28.17C31.1924 27.4687 30.7562 26.8554 30.1652 26.42C29.5741 25.9845 28.8591 25.7497 28.125 25.75ZM28.125 14.75C28.5329 14.75 28.9317 14.871 29.2708 15.0976C29.61 15.3242 29.8744 15.6463 30.0305 16.0232C30.1866 16.4001 30.2274 16.8148 30.1478 17.2149C30.0683 17.615 29.8718 17.9825 29.5834 18.2709C29.2949 18.5594 28.9274 18.7558 28.5273 18.8354C28.1273 18.915 27.7126 18.8741 27.3357 18.718C26.9588 18.5619 26.6367 18.2975 26.4101 17.9584C26.1834 17.6192 26.0625 17.2204 26.0625 16.8125C26.0625 16.2655 26.2798 15.7409 26.6666 15.3541C27.0533 14.9673 27.578 14.75 28.125 14.75ZM18.5 25.0625C18.092 25.0625 17.6933 24.9415 17.3541 24.7149C17.0149 24.4883 16.7506 24.1662 16.5945 23.7893C16.4384 23.4124 16.3975 22.9977 16.4771 22.5976C16.5567 22.1975 16.7531 21.83 17.0416 21.5416C17.33 21.2532 17.6975 21.0567 18.0976 20.9771C18.4977 20.8976 18.9124 20.9384 19.2892 21.0945C19.6661 21.2506 19.9882 21.515 20.2149 21.8541C20.4415 22.1933 20.5625 22.5921 20.5625 23C20.5625 23.547 20.3452 24.0716 19.9584 24.4584C19.5716 24.8452 19.047 25.0625 18.5 25.0625ZM28.125 31.25C27.717 31.25 27.3183 31.129 26.9791 30.9024C26.6399 30.6758 26.3756 30.3537 26.2195 29.9768C26.0634 29.5999 26.0225 29.1852 26.1021 28.7851C26.1817 28.385 26.3781 28.0175 26.6666 27.7291C26.955 27.4407 27.3225 27.2442 27.7226 27.1646C28.1227 27.0851 28.5374 27.1259 28.9142 27.282C29.2911 27.4381 29.6132 27.7025 29.8399 28.0416C30.0665 28.3808 30.1875 28.7796 30.1875 29.1875C30.1875 29.7345 29.9702 30.2591 29.5834 30.6459C29.1966 31.0327 28.672 31.25 28.125 31.25Z"
fill="#008836"
/>
</Svg>
</Animated.View>
{/* Top-right — lock */}
<Animated.View
style={[{ position: "absolute", top: 28, right: -5 }, styleTR]}
>
<Svg width={32} height={32} viewBox="0 0 32 32" fill="none">
<Rect
x={0.25}
y={0.25}
width={31.5}
height={31.5}
rx={7.75}
fill={isDark ? "#1A1D1B" : "#f7f7f7"}
stroke={isDark ? "#233C2D" : "#D3E8DB"}
strokeWidth={0.5}
/>
<Path
d="M16 14.125C15.475 14.1252 14.967 14.3144 14.57 14.658C14.172 15.0017 13.912 15.4769 13.836 15.9967C13.76 16.5165 13.874 17.0462 14.157 17.4891C14.439 17.9319 14.872 18.2583 15.375 18.4086V19.75C15.375 19.9158 15.441 20.0747 15.558 20.1919C15.675 20.3092 15.834 20.375 16 20.375C16.166 20.375 16.325 20.3092 16.442 20.1919C16.559 20.0747 16.625 19.9158 16.625 19.75V18.4086C17.128 18.2583 17.561 17.9319 17.843 17.4891C18.126 17.0462 18.24 16.5165 18.164 15.9967C18.088 15.4769 17.828 15.0017 17.43 14.658C17.033 14.3144 16.525 14.1252 16 14.125ZM16 17.25C15.815 17.25 15.633 17.195 15.479 17.092C15.325 16.989 15.205 16.8426 15.134 16.6713C15.063 16.5 15.044 16.3115 15.081 16.1296C15.117 15.9477 15.206 15.7807 15.337 15.6496C15.468 15.5185 15.635 15.4292 15.817 15.393C15.999 15.3568 16.187 15.3754 16.359 15.4464C16.53 15.5173 16.676 15.6375 16.78 15.7917C16.883 15.9458 16.937 16.1271 16.937 16.3125C16.937 16.5611 16.839 16.7996 16.663 16.9754C16.487 17.1512 16.249 17.25 16 17.25ZM22.25 11.625H19.75V9.75C19.75 8.7554 19.355 7.8016 18.652 7.0983C17.948 6.3951 16.995 6 16 6C15.005 6 14.052 6.3951 13.348 7.0983C12.645 7.8016 12.25 8.7554 12.25 9.75V11.625H9.75C9.418 11.625 9.101 11.7567 8.866 11.9911C8.632 12.2255 8.5 12.5435 8.5 12.875V21.625C8.5 21.9565 8.632 22.2745 8.866 22.5089C9.101 22.7433 9.418 22.875 9.75 22.875H22.25C22.582 22.875 22.899 22.7433 23.134 22.5089C23.368 22.2745 23.5 21.9565 23.5 21.625V12.875C23.5 12.5435 23.368 12.2255 23.134 11.9911C22.899 11.7567 22.582 11.625 22.25 11.625ZM13.5 9.75C13.5 9.087 13.763 8.4511 14.232 7.9822C14.701 7.5134 15.337 7.25 16 7.25C16.663 7.25 17.299 7.5134 17.768 7.9822C18.237 8.4511 18.5 9.087 18.5 9.75V11.625H13.5V9.75ZM22.25 21.625H9.75V12.875H22.25V21.625Z"
fill="#008836"
/>
</Svg>
</Animated.View>
{/* Bottom-left — document */}
<Animated.View
style={[{ position: "absolute", bottom: 0, left: 25 }, styleBL]}
>
<Svg width={35} height={35} viewBox="0 0 35 35" fill="none">
<Rect
x={0.25}
y={0.25}
width={34.5}
height={34.5}
rx={7.75}
fill={isDark ? "#1A1D1B" : "#f7f7f7"}
stroke={isDark ? "#233C2D" : "#D3E8DB"}
strokeWidth={0.5}
/>
<Rect
width={18.3333}
height={18.3333}
x={8.3334}
y={8.333}
fill={isDark ? "#1A1D1B" : "#f7f7f7"}
/>
<Path
d="M23.6346 14.178L19.6242 10.168C19.5709 10.114 19.5077 10.072 19.4382 10.044C19.3686 10.015 19.2941 10 19.2188 10H12.3438C12.0399 10 11.7485 10.121 11.5336 10.336C11.3187 10.551 11.198 10.842 11.198 11.146V23.75C11.198 24.054 11.3187 24.345 11.5336 24.56C11.7485 24.775 12.0399 24.896 12.3438 24.896H22.6563C22.9602 24.896 23.2517 24.775 23.4666 24.56C23.6814 24.345 23.8022 24.054 23.8022 23.75V14.583C23.8022 14.508 23.7875 14.434 23.7587 14.364C23.7299 14.295 23.6878 14.231 23.6346 14.178ZM19.7917 11.956L21.8464 14.01H19.7917V11.956ZM22.6563 23.75H12.3438V11.146H18.6459V14.583C18.6459 14.735 18.7063 14.881 18.8137 14.989C18.9212 15.096 19.0669 15.156 19.2188 15.156H22.6563V23.75ZM20.3647 18.021C20.3647 18.173 20.3043 18.319 20.1969 18.426C20.0894 18.533 19.9437 18.594 19.7917 18.594H15.2084C15.0565 18.594 14.9107 18.533 14.8033 18.426C14.6959 18.319 14.6355 18.173 14.6355 18.021C14.6355 17.869 14.6959 17.723 14.8033 17.616C14.9107 17.508 15.0565 17.448 15.2084 17.448H19.7917C19.9437 17.448 20.0894 17.508 20.1969 17.616C20.3043 17.723 20.3647 17.869 20.3647 18.021ZM20.3647 20.313C20.3647 20.465 20.3043 20.61 20.1969 20.718C20.0894 20.825 19.9437 20.885 19.7917 20.885H15.2084C15.0565 20.885 14.9107 20.825 14.8033 20.718C14.6959 20.61 14.6355 20.465 14.6355 20.313C14.6355 20.161 14.6959 20.015 14.8033 19.907C14.9107 19.8 15.0565 19.74 15.2084 19.74H19.7917C19.9437 19.74 20.0894 19.8 20.1969 19.907C20.3043 20.015 20.3647 20.161 20.3647 20.313Z"
fill="#008836"
/>
</Svg>
</Animated.View>
{/* Bottom-right — eye-off */}
<Animated.View
style={[{ position: "absolute", bottom: 0, right: 5 }, styleBR]}
>
<Svg width={43} height={43} viewBox="0 0 43 43" fill="none">
<Rect
x={0.25}
y={0.25}
width={42.5}
height={42.5}
rx={7.75}
fill={isDark ? "#1A1D1B" : "#f7f7f7"}
stroke={isDark ? "#233C2D" : "#D3E8DB"}
strokeWidth={0.5}
/>
<G>
<Path
d="M15.028 3.408C14.969 3.341 14.898 3.287 14.818 3.248C14.738 3.21 14.651 3.187 14.562 3.182C14.473 3.178 14.384 3.191 14.301 3.22C14.217 3.25 14.14 3.296 14.074 3.356C14.008 3.416 13.955 3.488 13.917 3.569C13.88 3.649 13.858 3.737 13.855 3.825C13.851 3.914 13.865 4.003 13.896 4.086C13.926 4.17 13.973 4.246 14.034 4.311L15.65 6.089C12.6 7.961 11.288 10.847 11.23 10.978C11.192 11.064 11.172 11.157 11.172 11.251C11.172 11.345 11.192 11.438 11.23 11.524C11.259 11.59 11.971 13.167 13.552 14.749C15.659 16.855 18.321 17.969 21.25 17.969C22.756 17.977 24.246 17.667 25.623 17.059L27.471 19.092C27.53 19.159 27.601 19.213 27.681 19.252C27.761 19.29 27.848 19.313 27.937 19.317C28.026 19.322 28.115 19.309 28.199 19.28C28.282 19.25 28.359 19.204 28.425 19.144C28.491 19.084 28.544 19.012 28.582 18.931C28.62 18.851 28.641 18.763 28.645 18.675C28.648 18.586 28.634 18.497 28.604 18.414C28.573 18.33 28.526 18.254 28.465 18.189L15.028 3.408ZM19.003 9.777L22.503 13.628C21.976 13.905 21.372 13.999 20.785 13.896C20.199 13.793 19.663 13.497 19.263 13.057C18.862 12.616 18.62 12.055 18.573 11.461C18.526 10.868 18.677 10.275 19.003 9.777ZM21.25 16.625C18.665 16.625 16.407 15.685 14.537 13.833C13.77 13.07 13.117 12.2 12.6 11.25C12.993 10.512 14.251 8.446 16.576 7.103L18.088 8.762C17.503 9.511 17.201 10.443 17.237 11.394C17.273 12.344 17.643 13.251 18.283 13.955C18.923 14.658 19.791 15.113 20.733 15.238C21.676 15.364 22.633 15.152 23.434 14.64L24.672 16.001C23.58 16.42 22.419 16.631 21.25 16.625ZM21.754 8.61C21.579 8.577 21.424 8.475 21.324 8.328C21.224 8.181 21.186 7.999 21.22 7.824C21.253 7.649 21.355 7.495 21.502 7.394C21.65 7.294 21.831 7.257 22.006 7.29C22.862 7.456 23.642 7.895 24.229 8.54C24.815 9.185 25.178 10.003 25.262 10.871C25.279 11.049 25.224 11.225 25.11 11.363C24.997 11.5 24.833 11.586 24.656 11.603C24.635 11.604 24.614 11.604 24.593 11.603C24.425 11.603 24.263 11.541 24.138 11.428C24.014 11.316 23.936 11.16 23.921 10.993C23.864 10.416 23.623 9.872 23.232 9.442C22.842 9.013 22.323 8.721 21.754 8.61ZM31.268 11.524C31.232 11.603 30.382 13.486 28.466 15.202C28.4 15.263 28.324 15.31 28.24 15.34C28.156 15.371 28.067 15.384 27.978 15.38C27.889 15.376 27.802 15.354 27.721 15.316C27.641 15.277 27.569 15.223 27.509 15.157C27.45 15.091 27.404 15.013 27.375 14.929C27.346 14.845 27.334 14.755 27.339 14.666C27.345 14.577 27.368 14.491 27.408 14.411C27.447 14.331 27.502 14.26 27.57 14.201C28.51 13.357 29.299 12.359 29.905 11.25C29.386 10.299 28.732 9.429 27.963 8.666C26.093 6.815 23.835 5.875 21.25 5.875C20.705 5.874 20.162 5.918 19.624 6.007C19.537 6.022 19.447 6.02 19.36 6.001C19.274 5.982 19.192 5.945 19.119 5.894C19.047 5.843 18.985 5.778 18.938 5.703C18.891 5.627 18.859 5.544 18.844 5.456C18.83 5.369 18.833 5.279 18.853 5.193C18.873 5.106 18.91 5.025 18.962 4.953C19.014 4.88 19.079 4.819 19.155 4.773C19.231 4.726 19.315 4.695 19.402 4.682C20.013 4.581 20.631 4.53 21.25 4.531C24.179 4.531 26.841 5.645 28.948 7.752C30.529 9.333 31.241 10.912 31.27 10.978C31.308 11.064 31.328 11.157 31.328 11.251C31.328 11.345 31.308 11.438 31.27 11.524H31.268Z"
fill="#008836"
transform="translate(0, 10)"
/>
</G>
</Svg>
</Animated.View>
</Animated.View>
</GestureDetector>
);
};

View File

@@ -20,99 +20,92 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { Linking, useWindowDimensions, View } from "react-native";
import { SwiperFlatList } from "react-native-swiper-flatlist";
import { Platform, useWindowDimensions, View } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { SafeAreaView } from "react-native-safe-area-context";
import { Spacing } from "../../common/design/spacing";
import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
import useRotator from "../../hooks/use-rotator";
import Navigation from "../../services/navigation";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import SettingsService from "../../services/settings";
import { AuthMode } from "../auth/common";
import { Button } from "../ui/button";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import SettingsService from "../../services/settings";
import { SafeAreaView } from "react-native-safe-area-context";
import { IntroIllustration } from "./illustration";
import { ProgressPills } from "./progress-pills";
const Intro = () => {
const { colors } = useThemeColors();
const { width } = useWindowDimensions();
const insets = useGlobalSafeAreaInsets();
const isTablet = width > 600;
const rotator = useRotator([0], 10000, true);
const insets = useGlobalSafeAreaInsets();
const renderItem = React.useCallback(
({ item }: { item: (typeof strings.introData)[0] }) => (
<View
<Animated.View
entering={FadeIn}
exiting={FadeOut}
style={{
justifyContent: "center",
width: isTablet ? width / 2 : width,
paddingHorizontal: isTablet ? (width / 2) * 0.05 : width * 0.05
justifyContent: "flex-start"
}}
>
<View
style={{
flexDirection: "row"
gap: Spacing.LEVEL_2
}}
>
<View
style={{
width: 100,
height: 5,
backgroundColor: colors.primary.accent,
borderRadius: 2,
marginRight: 7
}}
/>
{item.headings?.map((heading, index) =>
heading.bold ? (
<Heading
key={heading.value()}
fontFamily="SEMI_BOLD"
fontSize="XXL"
>
{heading.value()}
</Heading>
) : (
<Paragraph
key={heading.value()}
style={{
marginTop: index !== 0 ? -5 : undefined
}}
fontFamily="MEDIUM"
fontSize="XL"
>
{heading.value()}
</Paragraph>
)
)}
<View
style={{
width: 20,
height: 5,
backgroundColor: colors.secondary.background,
borderRadius: 2
}}
/>
</View>
<View
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
maxWidth: "90%",
width: "100%"
}}
>
{item.headings?.map((heading) => (
<Heading
key={heading()}
style={{
marginBottom: 5
}}
extraBold
size={AppFontSize.xxl}
>
{heading()}
</Heading>
))}
{item.user ? (
<Paragraph fontFamily="MEDIUM" fontSize="XL">
{item.user}
</Paragraph>
) : null}
{item.body ? (
<Paragraph size={AppFontSize.sm}>{item.body()}</Paragraph>
<Paragraph color={colors.primary.paragraph} fontSize="SM">
{item.body()}
</Paragraph>
) : null}
{item.tesimonial ? (
<Paragraph
style={{
fontStyle: "italic",
fontSize: AppFontSize.lg
}}
fontSize="SM"
color={colors.secondary.paragraph}
onPress={() => {
Linking.openURL(item.link);
// Linking.openURL(item.link);
}}
>
{item.tesimonial()} {item.user}
{item.tesimonial()}
</Paragraph>
) : null}
</View>
</View>
</Animated.View>
),
[colors.primary.accent, colors.secondary.background, isTablet, width]
[colors.primary.paragraph, colors.secondary.paragraph]
);
return (
@@ -126,17 +119,25 @@ const Intro = () => {
<View
testID="notesnook.splashscreen"
style={{
flex: 1
flex: 1,
paddingTop: Platform.OS === "android" ? 60 - insets.top : undefined
}}
>
<View
style={{
flexDirection: "row",
paddingHorizontal: Spacing.LEVEL_3
}}
>
<ProgressPills activePillIndex={0} />
</View>
<View
style={[
{
width: "100%",
borderBottomWidth: 1,
borderBottomColor: colors.primary.border,
paddingTop: insets.top + 10,
paddingBottom: insets.top + 10,
flexGrow: 1
},
isTablet && {
@@ -149,33 +150,34 @@ const Intro = () => {
}
]}
>
<SwiperFlatList
autoplay
autoplayDelay={10}
autoplayLoop={true}
index={0}
useReactNativeGestureHandler={true}
showPagination
data={strings.introData}
paginationActiveColor={colors.primary.accent}
paginationStyleItem={{
width: 10,
height: 5,
marginRight: 4,
marginLeft: 4
<View
style={{
alignItems: "center",
alignSelf: "center",
paddingVertical: Spacing.LEVEL_7
}}
paginationDefaultColor={colors.primary.border}
renderItem={renderItem}
/>
>
<IntroIllustration />
</View>
<View
style={{
paddingHorizontal: Spacing.LEVEL_3
}}
>
{strings.introData.map((item, index) =>
index !== rotator ? null : renderItem({ item })
)}
</View>
</View>
</View>
<View
style={{
width: isTablet ? "50%" : "100%",
justifyContent: "center",
gap: DefaultAppStyles.GAP_VERTICAL,
paddingHorizontal: isTablet ? 0 : DefaultAppStyles.GAP,
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
gap: Spacing.LEVEL_2,
padding: Spacing.LEVEL_3,
flexShrink: 1,
alignSelf: "center"
}}
@@ -191,7 +193,7 @@ const Intro = () => {
});
}}
type="accent"
title={strings.getStarted()}
title={strings.continue()}
/>
<Button

View File

@@ -19,39 +19,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { StyleSheet, View } from "react-native";
import { IconButton, IconButtonProps } from "../ui/icon-button";
import { View } from "react-native";
import { Spacing } from "../../common/design/spacing";
export const RightMenus = ({
rightButton
}: {
rightButton?: IconButtonProps;
export const ProgressPills = (props: {
activePillIndex: number;
count?: number;
}) => {
const { colors } = useThemeColors();
return (
<View style={styles.rightBtnContainer}>
{rightButton ? (
<IconButton {...rightButton} color={colors.primary.icon} />
) : (
<View
style={{
flexDirection: "row",
gap: Spacing.LEVEL_1
}}
>
{new Array(props.count || 3).fill(0).map((item, index) => (
<View
key={`${item}-progress-pill-${props.count}`}
style={{
width: 40,
height: 40
width: props.activePillIndex === index ? 26 : 14,
height: 5,
backgroundColor:
props.activePillIndex === index
? colors.primary.accent
: colors.secondary.background,
borderRadius: 2
}}
/>
)}
))}
</View>
);
};
const styles = StyleSheet.create({
rightBtnContainer: {
flexDirection: "row",
alignItems: "center"
},
rightBtn: {
justifyContent: "center",
alignItems: "center"
}
});

View File

@@ -22,22 +22,22 @@ import {
GroupingByIdKey,
GroupingKey,
GroupOptions,
ItemType
ItemType,
Item,
VirtualizedGrouping
} from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { View } from "react-native";
import React, { RefObject } from "react";
import { FlatList, View } from "react-native";
import { Radius, Spacing } from "../../../common/design/spacing";
import { useIsCompactModeEnabled } from "../../../hooks/use-is-compact-mode-enabled";
import { presentSheet } from "../../../services/event-manager";
import SettingsService from "../../../services/settings";
import { RouteName } from "../../../stores/use-navigation-store";
import { AppFontSize } from "../../../utils/size";
import { DefaultAppStyles } from "../../../utils/styles";
import Sort from "../../sheets/sort";
import { IconButton } from "../../ui/icon-button";
import { Pressable } from "../../ui/pressable";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
type SectionHeaderProps = {
item: GroupHeader;
@@ -49,8 +49,9 @@ type SectionHeaderProps = {
group: GroupingKey;
groupId?: string;
type?: GroupingByIdKey;
onOpenJumpToDialog: () => void;
itemCount?: number;
data?: VirtualizedGrouping<Item>;
ref?: RefObject<FlatList>;
};
export const SectionHeader = React.memo<
@@ -64,12 +65,13 @@ export const SectionHeader = React.memo<
screen,
groupOptions,
group,
onOpenJumpToDialog,
itemCount,
groupId,
type
type,
data,
ref
}: SectionHeaderProps) {
const { colors } = useThemeColors();
const { colors } = useThemeColors("list");
const isCompactModeEnabled = useIsCompactModeEnabled(
dataType as "note" | "notebook" | "searchResult"
);
@@ -78,8 +80,9 @@ export const SectionHeader = React.memo<
<View
style={{
width: "100%",
paddingHorizontal: DefaultAppStyles.GAP,
marginBottom: DefaultAppStyles.GAP_VERTICAL
paddingHorizontal: Spacing.LEVEL_3,
marginBottom: Spacing.LEVEL_3,
marginTop: Spacing.LEVEL_3
}}
>
<View
@@ -88,58 +91,36 @@ export const SectionHeader = React.memo<
alignItems: "center",
width: "100%",
alignSelf: "center",
justifyContent: "space-between",
borderBottomWidth: 1,
borderColor: colors.primary.border,
paddingBottom: 1,
paddingTop:
index === 0
? DefaultAppStyles.GAP_VERTICAL
: DefaultAppStyles.GAP_VERTICAL_SMALL
justifyContent: "space-between"
}}
>
<Pressable
onPress={() => {
onOpenJumpToDialog();
}}
hitSlop={{ top: 10, left: 10, right: 30, bottom: 15 }}
<Paragraph
fontSize="MD"
style={{
justifyContent: "flex-start",
flexDirection: "row",
width: "auto"
alignSelf: "center",
textAlignVertical: "center"
}}
color={colors.secondary.heading}
>
<Heading
size={AppFontSize.xxs}
style={{
alignSelf: "center",
textAlignVertical: "center"
}}
color={color || colors.primary.accent}
>
{!item.title || item.title === ""
? screen === "Search"
? strings.results(itemCount || 0)
: strings.pinned().toUpperCase()
: item.title.toUpperCase()}
</Heading>
</Pressable>
{!item.title || item.title === ""
? screen === "Search"
? strings.results(itemCount || 0)
: strings.pinned()
: item.title}
</Paragraph>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: DefaultAppStyles.GAP_SMALL
gap: Spacing.LEVEL_1
}}
>
{index === 0 ? (
<>
<IconButton
name={
groupOptions.sortDirection === "asc"
? "sort-ascending"
: "sort-descending"
}
name={"sliders"}
iconFamily="notesnook"
color={colors.secondary.icon}
testID="icon-sort"
onPress={() => {
@@ -155,15 +136,20 @@ export const SectionHeader = React.memo<
hideGroupOptions={
screen === "Reminders" || screen === "Search"
}
data={data}
ref={ref}
/>
)
});
}}
style={{
width: 25,
height: 25
width: 30,
height: 30,
borderWidth: 1,
borderRadius: Radius.XS,
borderColor: colors.secondary.border
}}
size={AppFontSize.lg - 2}
size={16}
/>
<IconButton
hidden={
@@ -173,14 +159,16 @@ export const SectionHeader = React.memo<
screen !== "Search"
}
style={{
width: 25,
height: 25
width: 30,
height: 30,
borderWidth: 1,
borderRadius: Radius.XXS,
borderColor: colors.secondary.border
}}
testID="icon-compact-mode"
color={colors.secondary.icon}
name={
isCompactModeEnabled ? "view-list" : "view-list-outline"
}
name={"view-list"}
iconFamily="notesnook"
onPress={() => {
SettingsService.set({
[dataType === "notebook"
@@ -192,20 +180,10 @@ export const SectionHeader = React.memo<
: "normal"
});
}}
size={AppFontSize.lg - 2}
size={16}
/>
</>
) : null}
{/* <IconButton
style={{
width: 25,
height: 25
}}
color={colors.secondary.icon}
name={"chevron-down"}
size={SIZE.lg - 2}
/> */}
</View>
</View>
</View>

View File

@@ -28,7 +28,6 @@ import { useThemeColors } from "@notesnook/theme";
import { EntityLevel, decode } from "entities";
import React from "react";
import { View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { useIsCompactModeEnabled } from "../../../hooks/use-is-compact-mode-enabled";
import useNavigationStore, {
RouteParams
@@ -55,6 +54,10 @@ import { TimeSince } from "../../ui/time-since";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
import dayjs from "dayjs";
import { ExpiryDate } from "../../ui/expiry-date";
import { Radius, Spacing } from "../../../common/design/spacing";
import { create } from "zustand";
import { FontFamily } from "../../../common/design/font";
type NoteItemProps = {
item: Note | BaseTrashItem<Note>;
@@ -71,6 +74,24 @@ type NoteItemProps = {
renderedInRoute?: keyof RouteParams;
};
const useShowMoreStore = create<{
showMoreStatus: Record<string, boolean>;
show: (id: string) => void;
hide: (id: string) => void;
}>((set) => ({
showMoreStatus: {},
show(id) {
set((state) => ({
showMoreStatus: { ...state.showMoreStatus, [id]: true }
}));
},
hide(id) {
set((state) => ({
showMoreStatus: { ...state.showMoreStatus, [id]: false }
}));
}
}));
const NoteItem = ({
item,
isTrash,
@@ -97,68 +118,54 @@ const NoteItem = ({
const primaryColors = isEditingNote ? colors.selected : colors.primary;
const selectionMode = useSelectionStore((state) => state.selectionMode);
const [selected] = useIsSelected(item);
const showMore = useShowMoreStore((state) => state.showMoreStatus[item.id]);
const statusIcons = [
{
condition: item.conflicted,
name: "alert-circle",
color: colors.error.accent
},
{
condition: item.localOnly,
testID: "sync-off",
name: "sync-off",
color: primaryColors.icon
},
{
condition: item.readonly,
testID: "pencil-lock",
name: "pencil-lock",
color: primaryColors.icon
},
{
condition: item.pinned,
testID: "icon-pinned",
name: "pin",
color: primaryColors.icon
},
{
condition: !!locked,
testID: "lock",
name: "lock",
color: primaryColors.icon
},
{
condition: item.favorite,
testID: "star-filled",
name: "star-filled",
color: "orange"
}
];
return (
<>
<View
style={{
flexGrow: 1,
flexShrink: 1
flexShrink: 1,
gap: compactMode ? undefined : Spacing.LEVEL_1
}}
>
{compactMode ? null : (
<Paragraph
style={{
fontSize: AppFontSize.xxxs,
color: colors.secondary.paragraph
}}
>
{getFormattedDate(
date,
dayjs(date).isBefore(dayjs().subtract(1, "day").hour(23))
? "date"
: "time"
)}
</Paragraph>
)}
{compactMode ? (
<Paragraph
numberOfLines={1}
color={color?.colorCode || primaryColors.heading}
size={AppFontSize.sm}
style={{
paddingRight: 10
}}
>
{item.title}
</Paragraph>
) : (
<Heading
numberOfLines={1}
color={color?.colorCode || primaryColors.heading}
size={AppFontSize.sm}
style={{
paddingRight: 10
}}
>
{item.title}
</Heading>
)}
{item.headline && !compactMode ? (
<Paragraph
style={{
flexWrap: "wrap"
}}
color={primaryColors.paragraph}
numberOfLines={2}
>
{decode(item.headline, {
level: EntityLevel.HTML
})}
</Paragraph>
) : null}
{compactMode ? null : (
<View
style={{
@@ -166,134 +173,89 @@ const NoteItem = ({
justifyContent: "flex-start",
alignItems: "center",
width: "100%",
marginTop: DefaultAppStyles.GAP_VERTICAL_SMALL,
columnGap: 8,
rowGap: 4,
columnGap: Spacing.LEVEL_0,
rowGap: Spacing.LEVEL_0,
flexWrap: "wrap"
}}
>
{!isTrash ? (
<>
{item.conflicted ? (
<Icon
name="alert-circle"
size={AppFontSize.sm}
color={colors.error.accent}
/>
) : null}
{statusIcons
.filter((statusIcon) => statusIcon.condition)
.map((statusIcon) => (
<View
key={statusIcon.testID || statusIcon.name}
style={{
borderRadius: Radius.XXS,
paddingHorizontal: 3,
paddingVertical: 3,
// borderWidth: 1,
// borderColor: primaryColors.border,
flexDirection: "row",
alignItems: "center"
}}
>
<AppIcon
testID={statusIcon.testID}
name={statusIcon.name}
size={12}
iconFamily="notesnook"
color={statusIcon.color}
/>
</View>
))}
{item.localOnly ? (
<Icon
testID="sync-off"
name="sync-off"
size={AppFontSize.sm}
color={primaryColors.icon}
/>
) : null}
{item.readonly ? (
<Icon
testID="pencil-lock"
name="pencil-lock"
size={AppFontSize.sm}
color={primaryColors.icon}
/>
) : null}
{attachmentsCount > 0 ? (
{attachmentsCount !== 0 ? (
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 2
borderRadius: Radius.XXS,
paddingHorizontal: 3,
paddingVertical: 3,
gap: Spacing.LEVEL_0
}}
>
<Icon
name="attachment"
size={AppFontSize.sm}
color={primaryColors.icon}
<AppIcon
name="link"
iconFamily="notesnook"
size={12}
color={colors.secondary.icon}
/>
<Paragraph
color={colors.secondary.paragraph}
size={AppFontSize.xxs}
>
<Paragraph color={colors.secondary.paragraph} fontSize="XS">
{attachmentsCount}
</Paragraph>
</View>
) : null}
{item.pinned ? (
<Icon
testID="icon-pinned"
name="pin-outline"
size={AppFontSize.sm}
color={color?.colorCode || primaryColors.accent}
/>
) : null}
{locked ? (
<Icon
name="lock"
testID="note-locked-icon"
size={AppFontSize.sm}
color={primaryColors.icon}
/>
) : null}
{item.favorite ? (
<Icon
testID="icon-star"
name="star-outline"
size={AppFontSize.sm}
color="orange"
/>
) : null}
{reminder ? (
<ReminderTime
reminder={reminder}
color={color?.colorCode}
textStyle={{
fontSize: AppFontSize.xxs
}}
short
iconSize={AppFontSize.xxs}
style={{
justifyContent: "flex-start",
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL / 2,
alignSelf: "flex-start"
}}
/>
) : null}
{notebooks?.items
?.filter(
(item) =>
renderedInRoute !== "Notebook" ||
item.id !== useNavigationStore.getState().focusedRouteId
)
.filter((_, index) => showMore || index < 1)
.map((item) => (
<View
key={item.id}
style={{
borderRadius: 4,
borderRadius: 100,
paddingHorizontal: Spacing.LEVEL_1,
paddingVertical: 2,
backgroundColor: colors.secondary.background,
paddingHorizontal: DefaultAppStyles.GAP_SMALL / 2,
borderWidth: 0.5,
borderColor: primaryColors.border,
paddingVertical: 1,
flexDirection: "row",
alignItems: "center",
gap: DefaultAppStyles.GAP_SMALL / 2
gap: Spacing.LEVEL_0
}}
>
<AppIcon
name="book-outline"
size={AppFontSize.xxxs}
name="bookmark"
iconFamily="notesnook"
size={AppFontSize.xs}
color={colors.secondary.icon}
/>
<Paragraph
size={AppFontSize.xxxs}
fontSize="XS"
color={colors.secondary.paragraph}
>
{item.title}
@@ -301,31 +263,64 @@ const NoteItem = ({
</View>
))}
{!isTrash && !compactMode && tags
? tags.items?.map((item) =>
item.id ? (
<View
key={item.id}
style={{
borderRadius: 4,
backgroundColor: colors.secondary.background,
paddingHorizontal: DefaultAppStyles.GAP_SMALL / 2,
borderWidth: 0.5,
borderColor:
color?.colorCode || primaryColors.border,
paddingVertical: 1
}}
{tags?.items
?.filter((_, index) => showMore || index < 1)
.map((item) =>
item.id ? (
<View
key={item.id}
style={{
borderRadius: 100,
paddingHorizontal: Spacing.LEVEL_1,
paddingVertical: 2,
backgroundColor: colors.secondary.background,
flexDirection: "row",
alignItems: "center",
gap: Spacing.LEVEL_0
}}
>
<Paragraph
size={AppFontSize.xs}
color={colors.secondary.paragraph}
>
<Paragraph
size={AppFontSize.xxxs}
color={colors.secondary.paragraph}
>
#{item.title}
</Paragraph>
</View>
) : null
)
: null}
{item.title}
</Paragraph>
</View>
) : null
)}
{(() => {
const filteredNotebooks = (notebooks?.items || []).filter(
(nb) =>
renderedInRoute !== "Notebook" ||
nb.id !== useNavigationStore.getState().focusedRouteId
);
const filteredTags = (tags?.items || []).filter((t) => t.id);
const totalNotebooks = filteredNotebooks.length;
const totalTags = filteredTags.length;
const hasMore = totalNotebooks > 1 || totalTags > 1;
if (!hasMore) return null;
const hiddenCount =
(totalNotebooks > 1 ? totalNotebooks - 1 : 0) +
(totalTags > 1 ? totalTags - 1 : 0);
return (
<Heading
size={AppFontSize.xs}
color={colors.primary.accent}
onPress={() => {
if (showMore) {
useShowMoreStore.getState().hide(item.id);
} else {
useShowMoreStore.getState().show(item.id);
}
}}
>
{showMore
? "Show less"
: `+${hiddenCount} ${strings.more()}`}
</Heading>
);
})()}
</>
) : (
<>
@@ -356,6 +351,207 @@ const NoteItem = ({
)}
</View>
)}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: Spacing.LEVEL_1
}}
>
{color && !compactMode ? (
<View
style={{
width: 8,
height: 8,
borderRadius: 100,
backgroundColor: color?.colorCode
}}
/>
) : null}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
flexGrow: 1,
alignItems: "center"
}}
>
<Heading
numberOfLines={1}
color={primaryColors.heading}
size={AppFontSize.sm}
style={{
flexShrink: 1
}}
>
{item.title}
</Heading>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: Spacing.LEVEL_1
}}
>
{color ? (
<View
style={{
width: 8,
height: 8,
borderRadius: 100,
backgroundColor: color?.colorCode
}}
/>
) : null}
{compactMode ? (
<>
{item.conflicted ? (
<AppIcon
name="warning-circle"
size={12}
iconFamily="notesnook"
color={colors.error.accent}
/>
) : null}
{locked ? (
<AppIcon
name="lock"
testID="lock"
iconFamily="notesnook"
size={12}
color={primaryColors.icon}
/>
) : null}
{item.favorite ? (
<AppIcon
testID="icon-star"
name="star-filled"
iconFamily="notesnook"
size={12}
color={colors.static.orange}
/>
) : null}
<TimeSince
style={{
fontSize: AppFontSize.xs,
color: colors.secondary.paragraph,
marginRight: 6
}}
time={date}
updateFrequency={Date.now() - date < 60000 ? 2000 : 60000}
/>
</>
) : null}
<IconButton
testID={notesnook.listitem.menu}
color={colors.secondary.icon}
name="dots-three"
iconFamily="notesnook"
size={20}
onPress={() => !noOpen && Properties.present(item)}
style={{
justifyContent: "center",
height: undefined,
width: undefined,
borderRadius: 100,
alignItems: "center"
}}
/>
</View>
</View>
</View>
{item.headline && !compactMode ? (
<Paragraph
style={{
flexWrap: "wrap"
}}
color={primaryColors.paragraph}
numberOfLines={2}
>
{decode(item.headline, {
level: EntityLevel.HTML
})}
</Paragraph>
) : null}
<View
style={{
flexDirection: "row",
gap: Spacing.LEVEL_1
}}
>
{compactMode ? null : (
<View
style={{
gap: Spacing.LEVEL_0 + 2,
flexDirection: "row"
}}
>
<AppIcon
color={colors.secondary.icon}
size={13}
name="calendar"
iconFamily="notesnook"
/>
<Paragraph
style={{
fontSize: AppFontSize.xs,
color: colors.secondary.paragraph
}}
>
{getFormattedDate(
date,
dayjs(date).isBefore(dayjs().subtract(1, "day").hour(23))
? "date"
: "time"
)}
</Paragraph>
</View>
)}
{item.expiryDate?.value ? (
<ExpiryDate
note={item as Note}
color={color?.colorCode}
textStyle={{ fontSize: AppFontSize.xxs }}
short
iconSize={AppFontSize.xxs}
style={{
justifyContent: "flex-start",
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL / 2,
alignSelf: "flex-start"
}}
/>
) : null}
{reminder ? (
<ReminderTime
reminder={reminder}
color={color?.colorCode}
textStyle={{
fontSize: AppFontSize.xs,
fontFamily: FontFamily.REGULAR
}}
short
iconSize={AppFontSize.xxs}
style={{
justifyContent: "flex-start",
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL / 2,
alignSelf: "flex-start",
backgroundColor: colors.primary.shade
}}
/>
) : null}
</View>
</View>
<View
style={{
@@ -363,55 +559,6 @@ const NoteItem = ({
alignItems: "center"
}}
>
{compactMode ? (
<>
{item.conflicted ? (
<Icon
name="alert-circle"
style={{
marginRight: 6
}}
size={AppFontSize.sm}
color={colors.error.accent}
/>
) : null}
{locked ? (
<Icon
name="lock"
testID="note-locked-icon"
size={AppFontSize.sm}
style={{
marginRight: 6
}}
color={primaryColors.icon}
/>
) : null}
{item.favorite ? (
<Icon
testID="icon-star"
name="star-outline"
size={AppFontSize.sm}
style={{
marginRight: 6
}}
color={colors.static.orange}
/>
) : null}
<TimeSince
style={{
fontSize: AppFontSize.xxs,
color: colors.secondary.paragraph,
marginRight: 6
}}
time={date}
updateFrequency={Date.now() - date < 60000 ? 2000 : 60000}
/>
</>
) : null}
{selectionMode === "note" || selectionMode === "trash" ? (
<>
<View
@@ -429,22 +576,7 @@ const NoteItem = ({
/>
</View>
</>
) : (
<IconButton
testID={notesnook.listitem.menu}
color={colors.secondary.icon}
name="dots-horizontal"
size={AppFontSize.lg}
onPress={() => !noOpen && Properties.present(item)}
style={{
justifyContent: "center",
height: 35,
width: 35,
borderRadius: 100,
alignItems: "center"
}}
/>
)}
) : null}
</View>
</>
);

View File

@@ -36,7 +36,8 @@ import { useTabStore } from "../../../screens/editor/tiptap/use-tab-store";
import { editorController } from "../../../screens/editor/tiptap/utils";
import { RouteParams } from "../../../stores/use-navigation-store";
import NotePreview from "../../note-history/preview";
import SelectionWrapper, { selectItem } from "../selection-wrapper";
import SelectionWrapper from "../selection-wrapper";
import { selectItem } from "../../../stores/use-selection-store";
export const openNote = async (
item: Note,
@@ -94,6 +95,7 @@ type NoteWrapperProps = {
isRenderedInActionSheet: boolean;
locked?: boolean;
renderedInRoute?: keyof RouteParams;
hasGroupHeader?: boolean;
};
export const NoteWrapper = React.memo<
@@ -103,6 +105,7 @@ export const NoteWrapper = React.memo<
item,
index,
isRenderedInActionSheet,
hasGroupHeader,
...restProps
}: NoteWrapperProps) {
const isTrash = item.type === "trash";
@@ -113,6 +116,7 @@ export const NoteWrapper = React.memo<
onPress={() => openNote(item as Note, isTrash, isRenderedInActionSheet)}
isSheet={isRenderedInActionSheet}
item={item}
hasGroupHeader={hasGroupHeader}
index={index}
color={restProps.color?.colorCode}
>

View File

@@ -27,7 +27,8 @@ import Navigation from "../../../services/navigation";
import { useSelectionStore } from "../../../stores/use-selection-store";
import { useTrashStore } from "../../../stores/use-trash-store";
import { presentDialog } from "../../dialog/functions";
import SelectionWrapper, { selectItem } from "../selection-wrapper";
import SelectionWrapper from "../selection-wrapper";
import { selectItem } from "../../../stores/use-selection-store";
import { strings } from "@notesnook/intl";
export const openNotebook = (item: Notebook | BaseTrashItem<Notebook>) => {

View File

@@ -36,7 +36,8 @@ import { IconButton } from "../../ui/icon-button";
import { ReminderTime } from "../../ui/reminder-time";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
import SelectionWrapper, { selectItem } from "../selection-wrapper";
import SelectionWrapper from "../selection-wrapper";
import { selectItem } from "../../../stores/use-selection-store";
const ReminderItem = React.memo(
({

View File

@@ -23,26 +23,9 @@ import React, { PropsWithChildren, useRef } from "react";
import { useIsCompactModeEnabled } from "../../../hooks/use-is-compact-mode-enabled";
import { useTabStore } from "../../../screens/editor/tiptap/use-tab-store";
import { useSelectionStore } from "../../../stores/use-selection-store";
import { DefaultAppStyles } from "../../../utils/styles";
import { Pressable } from "../../ui/pressable";
import { View } from "react-native";
export function selectItem(item: Item) {
if (useSelectionStore.getState().selectionMode === item.type) {
const { selectionMode, clearSelection, setSelectedItem } =
useSelectionStore.getState();
if (selectionMode === item.type) {
setSelectedItem(item.id);
}
if (useSelectionStore.getState().selectedItemsList.length === 0) {
clearSelection();
}
return true;
}
return false;
}
import { Spacing } from "../../../common/design/spacing";
type SelectionWrapperProps = PropsWithChildren<{
item: Item;
@@ -51,6 +34,7 @@ type SelectionWrapperProps = PropsWithChildren<{
isSheet?: boolean;
color?: string;
index?: number;
hasGroupHeader?: boolean;
}>;
const SelectionWrapper = ({
@@ -60,6 +44,7 @@ const SelectionWrapper = ({
isSheet,
children,
color,
hasGroupHeader,
index = 0
}: SelectionWrapperProps) => {
const itemId = useRef(item.id);
@@ -86,47 +71,50 @@ const SelectionWrapper = ({
};
return (
<Pressable
customColor={
isEditingNote
? colors.selected.background
: isSheet
? colors.primary.hover
: "transparent"
}
testID={testID}
onLongPress={onLongPress}
onPress={onPress}
customSelectedColor={colors.primary.hover}
customAlpha={!isDark ? -0.02 : 0.02}
customOpacity={1}
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
width: "100%",
alignSelf: "center",
overflow: "hidden",
paddingHorizontal: DefaultAppStyles.GAP,
paddingVertical: compactMode ? 4 : DefaultAppStyles.GAP_VERTICAL,
borderRadius: isSheet ? 10 : 0,
marginBottom: isSheet ? DefaultAppStyles.GAP_VERTICAL : undefined
// paddingHorizontal: Spacing.LEVEL_3,
backgroundColor: isEditingNote ? colors.selected.background : undefined
}}
>
{isEditingNote ? (
<Pressable
customColor={isSheet ? colors.primary.hover : "transparent"}
testID={testID}
onLongPress={onLongPress}
onPress={onPress}
customSelectedColor={colors.primary.hover}
noborder
customAlpha={!isDark ? -0.02 : 0.02}
customOpacity={1}
style={{
width: "100%",
alignSelf: "center",
overflow: "hidden",
paddingHorizontal: Spacing.LEVEL_3,
borderRadius: 0,
alignItems: "flex-start",
// paddingVertical: Spacing.LEVEL_2,
paddingTop: Spacing.LEVEL_2
}}
>
<View
style={{
backgroundColor: color || colors.selected.accent,
position: "absolute",
bottom: 0,
top: 0,
left: 0,
width: 5
width: "100%",
flexDirection: "row"
}}
>
{children}
</View>
<View
style={{
marginTop: Spacing.LEVEL_2,
borderBottomWidth: 1,
borderBottomColor: colors.primary.border,
width: "100%"
}}
/>
) : null}
{children}
</Pressable>
</Pressable>
</View>
);
};

View File

@@ -28,7 +28,8 @@ import { Properties } from "../../properties";
import { IconButton } from "../../ui/icon-button";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
import SelectionWrapper, { selectItem } from "../selection-wrapper";
import SelectionWrapper from "../selection-wrapper";
import { selectItem } from "../../../stores/use-selection-store";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../../utils/styles";

View File

@@ -21,10 +21,11 @@ import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { Dimensions, View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { Radius, Spacing } from "../../common/design/spacing";
import { Message, useMessageStore } from "../../stores/use-message-store";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import AppIcon from "../ui/AppIcon";
import { Pressable } from "../ui/pressable";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
export const Card = ({
@@ -47,15 +48,19 @@ export const Card = ({
<View
style={{
width: "100%",
paddingHorizontal: DefaultAppStyles.GAP,
paddingVertical: DefaultAppStyles.GAP_VERTICAL
paddingHorizontal: Spacing.LEVEL_3,
marginBottom: Spacing.LEVEL_4,
marginTop: Spacing.LEVEL_3
}}
>
<Pressable
onPress={messageBoardState.onPress}
type="plain"
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
paddingVertical: Spacing.LEVEL_3,
paddingHorizontal: Spacing.LEVEL_2,
backgroundColor: colors.primary.shade,
borderRadius: Radius.S,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
@@ -66,23 +71,24 @@ export const Card = ({
style={{
flexDirection: "row",
alignItems: "center",
width: "100%"
width: "100%",
flexShrink: 1,
gap: Spacing.LEVEL_1
}}
>
<View
style={{
width: 40 * fontScale,
height: 40 * fontScale,
borderRadius: 100,
width: 34,
height: 34,
borderRadius: Radius.XXS,
alignItems: "center",
justifyContent: "center"
justifyContent: "center",
backgroundColor: colors.secondary.background
}}
>
<Icon
size={AppFontSize.xxxl}
color={
messageBoardState.type === "error" ? colors.error.icon : color
}
size={16}
color={colors.primary.icon}
allowFontScaling
name={messageBoardState.icon}
/>
@@ -90,25 +96,27 @@ export const Card = ({
<View
style={{
marginLeft: 10,
marginRight: 10
gap: Spacing.LEVEL_0,
flexShrink: 1
}}
>
<Paragraph
<Heading
style={{
flexWrap: "nowrap",
flexShrink: 1
}}
size={AppFontSize.sm}
fontSize="MD"
color={colors.primary.heading}
>
{messageBoardState.actionText}
</Paragraph>
<Paragraph color={colors.secondary.paragraph} size={AppFontSize.xs}>
</Heading>
<Paragraph color={colors.secondary.paragraph} fontSize="SM">
{messageBoardState.message}
</Paragraph>
</View>
</View>
<AppIcon size={24} name="chevron-right" color={colors.primary.icon} />
</Pressable>
</View>
);

View File

@@ -21,13 +21,10 @@ import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { ActivityIndicator, View } from "react-native";
import { notesnook } from "../../../e2e/test.ids";
import { TTip, useTip } from "../../services/tip-manager";
import { Spacing } from "../../common/design/spacing";
import { RouteParams } from "../../stores/use-navigation-store";
import { useSettingStore } from "../../stores/use-setting-store";
import { AppFontSize } from "../../utils/size";
import { Tip } from "../tip";
import { Button } from "../ui/button";
import Seperator from "../ui/seperator";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
@@ -58,22 +55,13 @@ export const Empty = React.memo(function Empty({
screen
}: EmptyListProps) {
const { colors } = useThemeColors();
const introCompleted = useSettingStore(
(state) => state.settings.introCompleted
);
const tip = useTip(
screen === "Notes" && introCompleted
? "first-note"
: placeholder?.type || ((dataType + "s") as any),
screen === "Notes" ? "notes" : "list"
);
return (
<View
style={[
{
flex: 1,
width: "80%",
width: "100%",
justifyContent: "center",
alignSelf: "center"
}
@@ -81,50 +69,53 @@ export const Empty = React.memo(function Empty({
>
{!loading ? (
<>
<Tip
color={color}
tip={
screen !== "Search"
? tip || ({ text: () => placeholder?.paragraph } as TTip)
: ({ text: () => placeholder?.paragraph } as TTip)
}
<View
style={{
backgroundColor: "transparent",
paddingHorizontal: 0
alignItems: "center",
gap: Spacing.LEVEL_1
}}
/>
{placeholder?.button && (
<Button
testID={notesnook.buttons.add}
type="secondaryAccented"
title={placeholder?.button}
iconPosition="right"
icon="arrow-right"
onPress={placeholder?.action}
>
<Heading size={AppFontSize.md} color={colors.primary.heading}>
{placeholder?.title}
</Heading>
<Paragraph
style={{
alignSelf: "flex-start"
textAlign: "center",
marginBottom: Spacing.LEVEL_2,
maxWidth: "80%"
}}
/>
)}
fontSize="SM"
color={colors.primary.paragraph}
>
{placeholder?.paragraph}
</Paragraph>
{placeholder?.button && (
<Button
testID={notesnook.buttons.add}
type="accent"
title={placeholder?.button}
iconPosition="right"
onPress={placeholder?.action}
/>
)}
</View>
</>
) : (
<>
<View
style={{
alignSelf: "center",
alignItems: "flex-start",
width: "100%"
alignItems: "center",
width: "100%",
gap: Spacing.LEVEL_2
}}
>
<Heading>{placeholder?.title}</Heading>
<Paragraph size={AppFontSize.sm} textBreakStrategy="balanced">
{placeholder?.loading}
</Paragraph>
<Seperator />
<ActivityIndicator
size={AppFontSize.lg}
color={color || colors.primary.accent}
/>
<Paragraph>{placeholder?.loading}</Paragraph>
</View>
</>
)}

View File

@@ -43,9 +43,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react";
import { View } from "react-native";
import { getGroupOptions } from "../../hooks/use-group-options";
import { useIsCompactModeEnabled } from "../../hooks/use-is-compact-mode-enabled";
import { eSendEvent } from "../../services/event-manager";
import { RouteName } from "../../stores/use-navigation-store";
import { eOpenJumpToDialog } from "../../utils/events";
import { SectionHeader } from "../list-items/headers/section-header";
import { NoteWrapper } from "../list-items/note/wrapper";
import { NotebookWrapper } from "../list-items/notebook/wrapper";
@@ -190,12 +188,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
eSendEvent(eOpenJumpToDialog, {
ref: props.scrollRef,
data: items
});
}}
ref={props.scrollRef}
data={items}
/>
) : null}
@@ -205,6 +199,9 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
color={color.current}
notebooks={notebooks.current}
reminder={reminder.current}
hasGroupHeader={
groupHeader && previousIndex.current === index && !isSheet
}
attachmentsCount={attachmentsCount.current}
date={getDate(item as Note, group)}
isRenderedInActionSheet={isSheet}
@@ -229,12 +226,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
eSendEvent(eOpenJumpToDialog, {
ref: props.scrollRef,
data: items
});
}}
ref={props.scrollRef}
data={items}
/>
) : null}
<NotebookWrapper
@@ -260,12 +253,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
type={props.type}
groupId={props.groupId}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
eSendEvent(eOpenJumpToDialog, {
ref: props.scrollRef,
data: items
});
}}
ref={props.scrollRef}
data={items}
/>
) : null}
<ReminderItem
@@ -289,12 +278,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
eSendEvent(eOpenJumpToDialog, {
ref: props.scrollRef,
data: items
});
}}
ref={props.scrollRef}
data={items}
/>
) : null}
<TagItem
@@ -319,12 +304,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
color={props.customAccentColor}
groupOptions={groupOptions}
itemCount={items?.placeholders.length}
onOpenJumpToDialog={() => {
eSendEvent(eOpenJumpToDialog, {
ref: props.scrollRef,
data: items
});
}}
ref={props.scrollRef}
data={items}
/>
) : null}
<SearchResult item={item as HighlightedResult} />

View File

@@ -18,15 +18,19 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { View } from "react-native";
import { View, ViewStyle } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { ProgressBarComponent } from "../ui/svg/lazy";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { SvgView } from "../ui/svg";
import { Radius, Spacing } from "../../common/design/spacing";
export const Loading = (props: {
title?: string;
description?: string;
icon?: string;
svgSrc?: string;
style?: ViewStyle;
}) => {
const { colors } = useThemeColors();
return (
@@ -37,18 +41,38 @@ export const Loading = (props: {
backgroundColor: colors.primary.background,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: 16
paddingHorizontal: Spacing.LEVEL_3,
...props.style
}}
>
{props.icon ? (
<Icon name={props.icon} size={80} color={colors.primary.accent} />
) : null}
{props.svgSrc ? (
<View
style={{
marginBottom: Spacing.LEVEL_7
}}
>
<SvgView
src={props.svgSrc}
style={{
width: 180,
height: 180
}}
/>
</View>
) : null}
{props.title ? (
<Heading
style={{
textAlign: "center"
textAlign: "center",
marginBottom: Spacing.LEVEL_1
}}
fontSize="XL"
lineHeight={undefined}
>
{props.title}
</Heading>
@@ -57,8 +81,10 @@ export const Loading = (props: {
{props.description ? (
<Paragraph
style={{
textAlign: "center"
textAlign: "center",
marginBottom: Spacing.LEVEL_4
}}
fontSize="SM"
>
{props.description}
</Paragraph>
@@ -67,15 +93,15 @@ export const Loading = (props: {
<View
style={{
flexDirection: "row",
width: 100,
marginTop: 15
width: 300
}}
>
<ProgressBarComponent
height={5}
width={100}
height={7}
width={300}
animated={true}
useNativeDriver
borderRadius={Radius.XS}
indeterminate
indeterminateAnimationDuration={2000}
unfilledColor={colors.secondary.background}

View File

@@ -16,10 +16,9 @@ GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { SQLite } from "@notesnook/desktop";
import { expose } from "comlink";
export type SQLiteWorker = typeof SQLite.prototype;
const db = new SQLite();
expose(db);
export const Steps = {
select: 1,
buy: 2,
finish: 3,
buyWeb: 4
};

View File

@@ -0,0 +1,185 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { getFeaturesTable } from "@notesnook/common";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { ScrollView, useWindowDimensions, View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
//@ts-ignore
import usePricingPlans from "../../hooks/use-pricing-plans";
import { AppFontSize } from "../../utils/size";
import { Button } from "../ui/button";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { Steps } from "./common";
import { Radius, Spacing } from "../../common/design/spacing";
export const ComparePlans = React.memo(
(props: {
pricingPlans?: ReturnType<typeof usePricingPlans>;
setStep: (step: number) => void;
}) => {
const { colors } = useThemeColors();
const { width } = useWindowDimensions();
const isTablet = width > 600;
return (
<ScrollView
horizontal
style={{
width: isTablet ? "100%" : undefined,
paddingLeft: Spacing.LEVEL_3
}}
contentContainerStyle={{
flexDirection: "column"
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
width: "100%",
gap: 10
}}
>
{["Features", "Free", "Essential", "Pro", "Believer"].map(
(plan, index) => (
<View
key={plan}
style={{
width: index === 0 ? 150 : 120,
paddingHorizontal: Spacing.LEVEL_1,
paddingVertical: Spacing.LEVEL_2,
backgroundColor:
index === 0 ? colors.secondary.background : undefined,
borderBottomWidth: index === 0 ? 1 : undefined,
borderBottomColor: colors.primary.border
}}
>
<Heading size={AppFontSize.sm}>{plan}</Heading>
</View>
)
)}
</View>
{getFeaturesTable().map((item, keyIndex) => {
return (
<View
key={`${item[0] + item[1]}`}
style={{
flexDirection: "row",
alignItems: "center",
width: "100%",
gap: 10
}}
>
{item.map((featureItem, index) => (
<View
style={{
width: index === 0 ? 150 : 120,
paddingHorizontal: Spacing.LEVEL_1,
paddingVertical: Spacing.LEVEL_2,
backgroundColor:
index === 0 ? colors.secondary.background : undefined,
borderBottomWidth: index === 0 ? 1 : undefined,
borderBottomColor: colors.primary.border
}}
key={item[0] + index}
>
{typeof featureItem === "string" ? (
<Heading size={AppFontSize.sm}>
{featureItem as string}
</Heading>
) : (
<>
{typeof featureItem.caption === "string" ||
typeof featureItem.caption === "number" ? (
<Paragraph>
{featureItem.caption === "infinity"
? "∞"
: featureItem.caption}
</Paragraph>
) : typeof featureItem.caption === "boolean" ? (
<>
{featureItem.caption === true ? (
<Icon
color={colors.primary.accent}
size={AppFontSize.sm}
name="check"
/>
) : (
<Paragraph color={colors.secondary.paragraph}>
-
</Paragraph>
)}
</>
) : null}
</>
)}
</View>
))}
</View>
);
})}
<View
style={{
flexDirection: "row",
alignItems: "center",
width: "100%",
gap: 10
}}
>
{["features", "free", "essential", "pro", "believer"].map(
(plan, index) => (
<View
key={plan + "btn"}
style={{
width: index === 0 ? 150 : 120,
paddingHorizontal: Spacing.LEVEL_1
}}
>
{plan !== "free" && plan !== "features" ? (
<Button
title={strings.select()}
type="accent"
fontSize={AppFontSize.xs}
style={{
paddingVertical: Spacing.LEVEL_1,
borderRadius: Radius.XS,
alignSelf: "flex-start"
}}
onPress={() => {
props.pricingPlans?.selectPlan(plan);
props.setStep(Steps.buy);
}}
/>
) : null}
</View>
)
)}
</View>
</ScrollView>
);
},
() => true
);
ComparePlans.displayName = "ComparePlans";

View File

@@ -0,0 +1,73 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React, { useState } from "react";
import { TouchableOpacity, View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
//@ts-ignore
import { AppFontSize } from "../../utils/size";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
export const FAQItem = (props: { question: string; answer: string }) => {
const [expanded, setExpanded] = useState(false);
const { colors } = useThemeColors();
return (
<TouchableOpacity
style={{
padding: 16,
backgroundColor: colors.secondary.background,
borderRadius: 10,
marginBottom: 10,
gap: 12
}}
activeOpacity={0.9}
onPress={() => {
setExpanded(!expanded);
}}
key={props.question}
>
<View
style={{
flexDirection: "row",
width: "100%",
justifyContent: "space-between"
}}
>
<Heading
style={{
flexShrink: 1
}}
size={AppFontSize.md}
>
{props.question}
</Heading>
<Icon
name={expanded ? "chevron-up" : "chevron-down"}
color={colors.secondary.icon}
size={AppFontSize.xxl}
/>
</View>
{expanded ? (
<Paragraph size={AppFontSize.md}>{props.answer}</Paragraph>
) : null}
</TouchableOpacity>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,315 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import {
ActivityIndicator,
Text,
useWindowDimensions,
View
} from "react-native";
import * as RNIap from "react-native-iap";
//@ts-ignore
import { Radius, Spacing } from "../../common/design/spacing";
import usePricingPlans, {
PlanOverView,
PricingPlan
} from "../../hooks/use-pricing-plans";
import PremiumService from "../../services/premium";
import { getElevationStyle } from "../../utils/elevation";
import { AppFontSize, defaultBorderRadius } from "../../utils/size";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { Steps } from "./common";
export const PricingPlanCard = ({
plan,
pricingPlans,
annualBilling,
setStep
}: {
plan: PricingPlan;
pricingPlans?: ReturnType<typeof usePricingPlans>;
annualBilling?: boolean;
setStep: (step: number) => void;
}) => {
const { colors } = useThemeColors();
const regionalDiscount =
plan.id === "pro" && annualBilling
? pricingPlans?.regionalDiscount
: undefined;
const { width } = useWindowDimensions();
const isTablet = width > 600;
const product =
plan.subscriptions?.[
regionalDiscount?.sku ||
`notesnook.${plan.id}.${annualBilling ? "yearly" : "monthly"}`
];
const WebPlan = pricingPlans?.getWebPlan(
plan.id,
annualBilling ? "yearly" : "monthly"
);
const price = pricingPlans?.getPrice(
pricingPlans.isGithubRelease && WebPlan
? WebPlan
: (product as RNIap.Subscription),
pricingPlans.hasTrialOffer(plan.id, product?.productId) ? 1 : 0,
annualBilling
);
const isSubscribed =
product?.productId &&
pricingPlans?.user?.subscription?.productId?.includes(plan.id) &&
pricingPlans.isSubscribed();
const isNotReady =
pricingPlans?.loadingPlans || (!price && !WebPlan?.price?.gross);
return (
<View
style={{
...getElevationStyle(3),
backgroundColor: colors.secondary.background,
borderWidth: 1,
borderColor: colors.primary.border,
borderRadius: Radius.LG,
padding: 16,
flexShrink: isTablet ? 1 : undefined,
flexDirection: "column",
justifyContent: "space-between",
gap: 6
}}
>
<View>
<View
style={{
flexDirection: "row",
alignItems: "center",
width: "100%",
justifyContent: "space-between",
marginBottom: Spacing.LEVEL_1
}}
>
<Heading size={AppFontSize.xl}>{plan.name} </Heading>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: Spacing.LEVEL_1
}}
>
{isSubscribed ? (
<View
style={{
backgroundColor: colors.primary.accent,
borderRadius: defaultBorderRadius,
padding: Spacing.LEVEL_0,
alignItems: "center",
justifyContent: "center",
alignSelf: "flex-start",
marginBottom: Spacing.LEVEL_1
}}
>
<Heading color={colors.static.white} size={AppFontSize.xxs}>
{strings.currentPlan()}
</Heading>
</View>
) : (
<>
{regionalDiscount?.discount || WebPlan?.discount ? (
<View
style={{
backgroundColor: colors.static.red,
borderRadius: 100,
padding: Spacing.LEVEL_0,
paddingHorizontal: Spacing.LEVEL_1,
justifyContent: "center",
alignSelf: "flex-start"
}}
>
<Heading color={colors.static.white} size={AppFontSize.xxs}>
{strings.percentOff(
`${regionalDiscount?.discount || WebPlan?.discount?.amount}`
)}
</Heading>
</View>
) : null}
{plan.recommended ? (
<View
style={{
backgroundColor: colors.static.red,
borderRadius: 100,
padding: Spacing.LEVEL_0,
paddingHorizontal: Spacing.LEVEL_1
}}
>
<Text
style={{
color: colors.static.white,
fontSize: AppFontSize.xxs
}}
>
{strings.recommended()}
</Text>
</View>
) : null}
</>
)}
</View>
</View>
<Paragraph>{plan.description}</Paragraph>
<View
style={{
marginTop: Spacing.LEVEL_3
}}
>
{pricingPlans?.loadingPlans || (!price && !WebPlan?.price?.gross) ? (
<ActivityIndicator size="small" color={colors.primary.accent} />
) : (
<View>
<Heading size={24}>
{price ||
`${WebPlan?.price?.currency} ${WebPlan?.price?.gross}`}{" "}
<Paragraph color={colors.secondary.heading} fontSize="SM">
/{strings.month()}
</Paragraph>
</Heading>
{!product && !WebPlan ? null : (
<Paragraph
color={colors.primary.paragraph}
size={AppFontSize.xs}
>
{annualBilling
? strings.billedAnnually(
pricingPlans?.getStandardPrice(
(product || WebPlan) as any
) as string
)
: strings.billedMonthly(
pricingPlans?.getStandardPrice(
(product || WebPlan) as any
) as string
)}
</Paragraph>
)}
</View>
)}
</View>
<View
style={{
marginVertical: Spacing.LEVEL_4,
gap: Spacing.LEVEL_2
}}
>
{Object.keys(PlanOverView[plan.id as keyof typeof PlanOverView]).map(
(item) => (
<View
key={item + plan.id}
style={{
flexDirection: "row",
width: "100%",
justifyContent: "space-between",
borderBottomColor: colors.primary.border,
borderBottomWidth: 1,
paddingBottom: Spacing.LEVEL_2
}}
>
<View
style={{
flexDirection: "row",
gap: Spacing.LEVEL_1
}}
>
<AppIcon
name={
item === "storage"
? "cloud"
: item === "fileSize"
? "file"
: "image-outline"
}
iconFamily="notesnook"
size={AppFontSize.lg}
/>
<Paragraph size={AppFontSize.sm}>
{strings[item as "storage" | "hdImages" | "fileSize"]()}
</Paragraph>
</View>
<Heading size={AppFontSize.sm}>
{
PlanOverView[plan.id as keyof typeof PlanOverView][
item as "storage" | "hdImages" | "fileSize"
]
}
</Heading>
</View>
)
)}
</View>
<Button
title={strings.selectPlan()}
type={plan.id === "pro" ? "accent" : "tertiary"}
style={{
width: "100%"
}}
onPress={() => {
if (isNotReady) return;
const currentPlanSubscribed =
PremiumService.get() &&
(pricingPlans?.user?.subscription?.productId ===
(product as RNIap.Subscription)?.productId ||
pricingPlans?.user?.subscription?.productId?.startsWith(
(product as RNIap.Subscription)?.productId
));
pricingPlans?.selectPlan(
plan.id,
currentPlanSubscribed
? `notesnook.${plan.id}.${
!(product as RNIap.Subscription)?.productId.includes(
"yearly"
)
? "yearly"
: "monthly"
}`
: pricingPlans.isGithubRelease
? (WebPlan?.period as string)
: (product?.productId as string)
);
setStep(Steps.buy);
}}
/>
</View>
</View>
);
};

View File

@@ -0,0 +1,84 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { Image, TouchableOpacity, View } from "react-native";
//@ts-ignore
import { openLinkInBrowser } from "../../utils/functions";
import { AppFontSize } from "../../utils/size";
import Paragraph from "../ui/typography/paragraph";
import { Radius, Spacing } from "../../common/design/spacing";
import Heading from "../ui/typography/heading";
export const ReviewItem = (props: {
review: string;
user: string;
userSource: string;
link: string;
userImage?: string;
}) => {
const { colors } = useThemeColors();
return (
<TouchableOpacity
activeOpacity={1}
onPress={() => {
openLinkInBrowser(props.link);
}}
style={{
width: "100%",
padding: Spacing.LEVEL_2,
borderWidth: 1,
backgroundColor: colors.secondary.background,
borderRadius: Radius.S,
borderColor: colors.primary.border
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: Spacing.LEVEL_1,
paddingBottom: Spacing.LEVEL_1
}}
>
{props.userImage ? (
<Image
source={{
uri: props.userImage
}}
style={{
width: 34,
height: 34,
borderRadius: 100
}}
/>
) : null}
<View>
<Heading size={AppFontSize.sm}>{props.user}</Heading>
<Paragraph size={AppFontSize.xs} color={colors.primary.paragraph}>
{props.userSource}
</Paragraph>
</View>
</View>
<Paragraph>{props.review}</Paragraph>
</TouchableOpacity>
);
};

View File

@@ -43,6 +43,8 @@ import ColorPicker from "../dialogs/color-picker";
import PaywallSheet from "../sheets/paywall";
import { Button } from "../ui/button";
import { Pressable } from "../ui/pressable";
import { Spacing } from "../../common/design/spacing";
import AppIcon from "../ui/AppIcon";
const ColorItem = ({ item, note }: { item: Color; note: Note }) => {
const { colors } = useThemeColors();
@@ -80,12 +82,11 @@ const ColorItem = ({ item, note }: { item: Color; note: Note }) => {
key={item.id}
onPress={toggleColor}
style={{
width: 35,
height: 35,
width: 40,
height: 40,
borderRadius: 100,
justifyContent: "center",
alignItems: "center",
marginRight: 5
alignItems: "center"
}}
>
{isLinked ? (
@@ -151,31 +152,10 @@ export const ColorTags = ({ item }: { item: Note }) => {
/>
<View
style={{
flexGrow: isTablet ? undefined : 1,
flexDirection: "row",
marginLeft: 5,
flexShrink: 2
flexDirection: "row"
}}
>
{!colorNotes || !colorNotes.length ? (
<Button
onPress={onPress}
buttonType={{
text: colors.primary.accent
}}
title={strings.addColor()}
type="secondary"
icon="plus"
iconPosition="right"
height={30}
fontSize={AppFontSize.xs}
style={{
marginRight: 5,
paddingHorizontal: DefaultAppStyles.GAP_SMALL,
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
}}
/>
) : (
{colorNotes?.length ? (
<LegendList
data={colorNotes}
estimatedItemSize={30}
@@ -184,11 +164,14 @@ export const ColorTags = ({ item }: { item: Note }) => {
bounces={false}
renderItem={renderItem}
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
gap: Spacing.LEVEL_1
}}
ListFooterComponent={
<Pressable
style={{
width: 35,
height: 35,
width: 40,
height: 40,
borderRadius: 100,
justifyContent: "center",
alignItems: "center",
@@ -197,16 +180,17 @@ export const ColorTags = ({ item }: { item: Note }) => {
type="secondary"
onPress={onPress}
>
<Icon
<AppIcon
testID="icon-plus"
iconFamily="notesnook"
name="plus"
color={colors.primary.icon}
color={colors.primary.accent}
size={AppFontSize.lg}
/>
</Pressable>
}
/>
)}
) : null}
</View>
</>
);

View File

@@ -29,6 +29,7 @@ import DateTimePickerModal from "react-native-modal-datetime-picker";
import { db } from "../../common/database";
import { Item, Note } from "@notesnook/core";
import AppIcon from "../ui/AppIcon";
import { Radius, Spacing } from "../../common/design/spacing";
export const DateMeta = ({ item }: { item: Item }) => {
const { colors, isDark } = useThemeColors();
const [isDatePickerVisible, setIsDatePickerVisible] = useState(false);
@@ -50,44 +51,52 @@ export const DateMeta = ({ item }: { item: Item }) => {
key={key}
style={{
flexDirection: "row",
width: "48.5%",
justifyContent: "space-between",
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL / 2
backgroundColor: colors.secondary.background,
borderRadius: Radius.XS,
padding: Spacing.LEVEL_2,
gap: Spacing.LEVEL_1,
alignItems: "center"
}}
>
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
{strings.dateDescFromKey(
key as
| "dateDeleted"
| "dateEdited"
| "dateModified"
| "dateCreated"
| "dateUploaded"
)}
</Paragraph>
<Paragraph
size={AppFontSize.xs}
color={colors.secondary.paragraph}
onPress={
item.type !== "note"
? undefined
: () => {
setIsDatePickerVisible(true);
}
}
>
{getFormattedDate(
key === "dateCreated"
? dateCreated
: (item[key as keyof Item] as string),
"date-time"
)}
{key === "dateCreated" && item.type === "note" ? (
<>
{" "}
<AppIcon name="pencil" size={AppFontSize.md} />
</>
) : null}
</Paragraph>
<View>
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
{strings.dateDescFromKey(
key as
| "dateDeleted"
| "dateEdited"
| "dateModified"
| "dateCreated"
| "dateUploaded"
)}
</Paragraph>
<Paragraph
size={AppFontSize.xs}
color={colors.primary.paragraph}
fontFamily="MEDIUM"
onPress={
item.type !== "note"
? undefined
: () => {
setIsDatePickerVisible(true);
}
}
>
{getFormattedDate(
key === "dateCreated"
? dateCreated
: (item[key as keyof Item] as string),
"date-time"
)}
</Paragraph>
</View>
{key === "dateCreated" && item.type === "note" ? (
<>
<AppIcon name="edit-pencil" size={10} iconFamily="notesnook" />
</>
) : null}
</View>
);
@@ -110,6 +119,7 @@ export const DateMeta = ({ item }: { item: Item }) => {
}}
maximumDate={new Date((item as Note).dateEdited)}
isDarkModeEnabled={isDark}
themeVariant={isDark ? "dark" : "light"}
is24Hour={db.settings.getTimeFormat() === "24-hour"}
date={new Date(dateCreated)}
/>
@@ -118,9 +128,10 @@ export const DateMeta = ({ item }: { item: Item }) => {
<View
style={{
borderTopWidth: 1,
borderTopColor: colors.primary.border,
paddingHorizontal: DefaultAppStyles.GAP,
paddingTop: DefaultAppStyles.GAP_VERTICAL_SMALL
borderColor: colors.primary.border,
paddingVertical: Spacing.LEVEL_2,
flexDirection: "row",
gap: Spacing.LEVEL_2
}}
>
{getDateMeta().map(renderItem)}

View File

@@ -18,14 +18,18 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import React, { useEffect, useState } from "react";
import { View } from "react-native";
import { FlatList } from "react-native-actions-sheet";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { db } from "../../common/database";
import { DDS } from "../../services/device-detection";
import { eSendEvent, presentSheet } from "../../services/event-manager";
import { eOnLoadNote } from "../../utils/events";
import {
eSendEvent,
presentSheet,
sendItemUpdateEvent,
ToastManager
} from "../../services/event-manager";
import { eOnLoadNote, refreshNotesPage } from "../../utils/events";
import { fluidTabsRef } from "../../utils/global-refs";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
@@ -37,27 +41,43 @@ import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { DateMeta } from "./date-meta";
import { Items } from "./items";
import Notebooks from "./notebooks";
import { TagStrip, Tags } from "./tags";
import { Tags } from "./tags";
import { Dialog } from "../dialog";
const Line = ({ top = 6, bottom = 6 }) => {
const { colors } = useThemeColors();
return (
<View
style={{
height: 1,
backgroundColor: colors.primary.border,
width: "100%",
marginTop: top,
marginBottom: bottom
}}
/>
);
};
import AppIcon from "../ui/AppIcon";
import { Spacing } from "../../common/design/spacing";
import { Button } from "../ui/button";
import ManageTags from "../../screens/manage-tags";
import ColorPicker from "../dialogs/color-picker";
import { useRelationStore } from "../../stores/use-relation-store";
import { useMenuStore } from "../../stores/use-menu-store";
import Navigation from "../../services/navigation";
import { useIsFeatureAvailable } from "@notesnook/common";
import PaywallSheet from "../sheets/paywall";
import { useSettingStore } from "../../stores/use-setting-store";
export const Properties = ({ close = () => {}, item, buttons = [] }) => {
const { colors } = useThemeColors();
const colorFeature = useIsFeatureAvailable("colors");
const [noteNotebooks, setNoteNotebooks] = useState([]);
const [tags, setTags] = useState([]);
const [visible, setVisible] = useState(false);
const colorNotes = useMenuStore((state) => state.colorNotes);
useEffect(() => {
async function getNotebooks() {
let filteredNotebooks = await db.relations.to(item, "notebook").resolve();
return filteredNotebooks || [];
}
if (item.type === "note") {
getNotebooks().then((notebooks) => setNoteNotebooks(notebooks));
db.relations
.to(item, "tag")
.resolve()
.then((tags) => {
setTags(tags);
});
}
}, [item]);
if (!item || !item.id) {
return (
<Paragraph style={{ marginVertical: 10, alignSelf: "center" }}>
@@ -74,7 +94,8 @@ export const Properties = ({ close = () => {}, item, buttons = [] }) => {
backgroundColor: colors.primary.background,
borderBottomRightRadius: DDS.isLargeTablet() ? 10 : 1,
borderBottomLeftRadius: DDS.isLargeTablet() ? 10 : 1,
maxHeight: "100%"
maxHeight: "100%",
paddingTop: Spacing.LEVEL_3
}}
nestedScrollEnabled
bounces={false}
@@ -83,111 +104,240 @@ export const Properties = ({ close = () => {}, item, buttons = [] }) => {
renderItem={() => (
<View
style={{
gap: DefaultAppStyles.GAP_VERTICAL
gap: Spacing.LEVEL_1
}}
>
{item.type === "note" ? (
<ColorPicker
visible={visible}
setVisible={setVisible}
onColorAdded={async (color) => {
await db.relations.to(item, "color").unlink();
await db.relations.add(color, item);
useRelationStore.getState().update();
useMenuStore.getState().setColorNotes();
Navigation.queueRoutesForUpdate();
sendItemUpdateEvent(color.id, "color");
eSendEvent(refreshNotesPage);
}}
/>
) : null}
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP
paddingHorizontal: Spacing.LEVEL_3
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between"
}}
>
<View>
<View
style={{
flexDirection: "row",
alignItems: "center",
flexShrink: 1,
gap: 5
gap: Spacing.LEVEL_1
}}
>
{item.type === "color" ? (
<Pressable
type="accent"
accentColor={item.colorCode}
accentText={colors.static.white}
{noteNotebooks?.map((item) => (
<View
key={item.id}
style={{
width: 30,
height: 30,
borderRadius: 100,
marginRight: 10
paddingHorizontal: Spacing.LEVEL_1,
paddingVertical: 2,
backgroundColor: colors.secondary.background,
flexDirection: "row",
alignItems: "center",
gap: Spacing.LEVEL_0
}}
/>
) : item.type === "tag" ? (
<Icon
name="pound"
size={AppFontSize.lg}
color={colors.primary.icon}
/>
) : null}
>
<AppIcon
name="bookmark"
iconFamily="notesnook"
size={AppFontSize.xs}
color={colors.secondary.icon}
/>
<Paragraph fontSize="XS" color={colors.secondary.paragraph}>
{item.title}
</Paragraph>
</View>
))}
<Heading size={AppFontSize.lg}>{item.title}</Heading>
{tags?.map((item) =>
item.id ? (
<View
key={item.id}
style={{
borderRadius: 100,
paddingHorizontal: Spacing.LEVEL_1,
paddingVertical: 2,
backgroundColor: colors.secondary.background,
flexDirection: "row",
alignItems: "center",
gap: Spacing.LEVEL_0
}}
>
<Paragraph
size={AppFontSize.xs}
color={colors.secondary.paragraph}
>
{item.title}
</Paragraph>
</View>
) : null
)}
</View>
{item.type === "note" ? (
<IconButton
name="open-in-new"
type="plain"
color={colors.primary.icon}
size={AppFontSize.lg}
<View
style={{
flexDirection: "row",
justifyContent: "space-between"
}}
>
<View
style={{
alignSelf: "flex-start"
flexDirection: "row",
alignItems: "center",
flexShrink: 1,
gap: Spacing.LEVEL_1
}}
onPress={() => {
close();
eSendEvent(eOnLoadNote, {
item: item,
newTab: true
});
if (!DDS.isTab) {
fluidTabsRef.current?.goToPage("editor");
}
>
{item.type === "color" ? (
<Pressable
type="accent"
accentColor={item.colorCode}
accentText={colors.static.white}
style={{
width: 8,
height: 8,
borderRadius: 100
}}
/>
) : item.type === "tag" ? (
<AppIcon
name="shopping-mode"
iconFamily="evilicons"
size={AppFontSize.lg}
color={colors.primary.icon}
/>
) : null}
<Heading size={AppFontSize.xl}>{item.title}</Heading>
</View>
{item.type === "note" ? (
<IconButton
name="square-out"
iconFamily="notesnook"
type="plain"
color={colors.primary.icon}
size={AppFontSize.lg}
style={{
alignSelf: "flex-start"
}}
onPress={() => {
close();
eSendEvent(eOnLoadNote, {
item: item,
newTab: true
});
if (!DDS.isTab) {
fluidTabsRef.current?.goToPage("editor");
}
}}
/>
) : null}
</View>
{(item.type === "notebook" || item.type === "reminder") &&
item.description ? (
<Paragraph>{item.description}</Paragraph>
) : null}
{item.type === "reminder" ? (
<ReminderTime
reminder={item}
style={{
justifyContent: "flex-start",
borderWidth: 0,
alignSelf: "flex-start",
backgroundColor: "transparent",
paddingHorizontal: 0,
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
}}
fontSize={AppFontSize.xs}
/>
) : null}
</View>
{(item.type === "notebook" || item.type === "reminder") &&
item.description ? (
<Paragraph>{item.description}</Paragraph>
<DateMeta item={item} />
{item.type === "note" && colorNotes.length > 0 ? (
<Tags close={close} item={item} />
) : null}
{item.type === "note" ? (
<TagStrip close={close} item={item} />
) : null}
{item.type === "reminder" ? (
<ReminderTime
reminder={item}
style={{
justifyContent: "flex-start",
borderWidth: 0,
alignSelf: "flex-start",
backgroundColor: "transparent",
paddingHorizontal: 0,
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
<View
style={{
flexDirection: "row",
borderBottomWidth: 1,
borderTopWidth: colorNotes.length > 0 ? 1 : 0,
borderColor: colors.primary.border,
paddingVertical: Spacing.LEVEL_2,
paddingTop: colorNotes.length > 0 ? Spacing.LEVEL_2 : 0,
gap: Spacing.LEVEL_2
}}
>
<Button
onPress={async () => {
ManageTags.present([item.id]);
close();
}}
buttonType={{
text: colors.primary.paragraph
}}
title={strings.addTag()}
type={colorNotes?.length ? "accent-outline" : "shade"}
icon="plus"
iconFamily="notesnook"
style={{
paddingHorizontal: Spacing.LEVEL_3,
paddingVertical: Spacing.LEVEL_2,
width: colorNotes?.length > 0 ? "100%" : "48.5%"
}}
fontSize={AppFontSize.xs}
/>
) : null}
{colorNotes.length > 0 ? null : (
<Button
onPress={() => {
if (colorFeature && !colorFeature.isAllowed) {
ToastManager.show({
message: colorFeature.error,
type: "info",
context: "local",
actionText: strings.upgrade(),
func: () => {
PaywallSheet.present(colorFeature);
ToastManager.hide();
}
});
return;
}
useSettingStore.getState().setSheetKeyboardHandler(false);
setVisible(true);
}}
title={strings.addColor()}
type="secondaryAccented"
icon="plus"
iconFamily="notesnook"
style={{
width: "48.5%",
paddingHorizontal: Spacing.LEVEL_3,
paddingVertical: Spacing.LEVEL_2
}}
/>
)}
</View>
</View>
<DateMeta item={item} />
<Line bottom={0} top={0} />
{item.type === "note" ? (
<>
<Tags close={close} item={item} />
<Line bottom={0} top={0} />
</>
) : null}
{item.type === "note" ? (
{/* {item.type === "note" ? (
<Notebooks note={item} close={close} />
) : null}
) : null} */}
<Items
item={item}
buttons={buttons}

View File

@@ -27,12 +27,13 @@ import { Action, ActionId, useActions } from "../../hooks/use-actions";
import { useStoredRef } from "../../hooks/use-stored-ref";
import { DDS } from "../../services/device-detection";
import { useSettingStore } from "../../stores/use-setting-store";
import { AppFontSize, defaultBorderRadius } from "../../utils/size";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import { Pressable } from "../ui/pressable";
import Paragraph from "../ui/typography/paragraph";
import { Radius, Spacing } from "../../common/design/spacing";
const TOP_BAR_ITEMS: ActionId[] = [
"pin",
@@ -42,7 +43,8 @@ const TOP_BAR_ITEMS: ActionId[] = [
"publish",
"local-only",
"read-only",
"pin-to-notifications"
"pin-to-notifications",
"spell-check"
];
const BOTTOM_BAR_ITEMS: ActionId[] = [
@@ -79,6 +81,8 @@ const COLUMN_BAR_ITEMS: ActionId[] = [
"rename-color",
"rename-tag",
"launcher-shortcut",
"copy-id",
"copy-link",
"restore",
"trash",
"delete"
@@ -149,7 +153,7 @@ export const Items = ({
key={item.id}
style={{
alignItems: "center",
width: columnItemWidth - 8,
width: columnItemWidth - 10,
opacity: item.locked ? 0.5 : 1
}}
>
@@ -158,12 +162,12 @@ export const Items = ({
type={item.checked ? "shade" : "secondary"}
testID={"icon-" + item.id}
style={{
height: columnItemWidth / 1.5,
width: columnItemWidth - 8,
width: columnItemWidth - 10,
paddingVertical: Spacing.LEVEL_2,
borderRadius: 10,
justifyContent: "center",
alignItems: "center",
marginBottom: DDS.isTab ? 7 : 3.5
marginBottom: 6
}}
>
<Icon
@@ -250,7 +254,7 @@ export const Items = ({
key={item.id}
testID={"icon-" + item.id}
style={{
width: columnItemWidth - 8,
width: columnItemWidth - 10,
alignSelf: "flex-start",
gap: DefaultAppStyles.GAP_VERTICAL_SMALL
}}
@@ -258,24 +262,25 @@ export const Items = ({
<View
style={{
height: columnItemWidth / 2,
width: columnItemWidth - DefaultAppStyles.GAP_SMALL,
width: 65,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderRadius: defaultBorderRadius,
borderColor: item.checked
? item.activeColor || colors.primary.accent
: colors.primary.border,
overflow: "hidden"
backgroundColor: item.checked
? colors.primary.shade
: colors.secondary.background,
borderRadius: Radius.XS,
overflow: "hidden",
paddingVertical: Spacing.LEVEL_2,
paddingHorizontal: Spacing.LEVEL_2
}}
>
<Icon
name={item.icon}
allowFontScaling
size={DDS.isTab ? AppFontSize.xxl : AppFontSize.md + 4}
size={16}
color={
item.checked
? item.activeColor || colors.primary.accent
? colors.primary.icon
: item.id === "delete" || item.id === "trash"
? colors.error.icon
: colors.secondary.icon
@@ -307,7 +312,13 @@ export const Items = ({
<Paragraph
textBreakStrategy="simple"
size={AppFontSize.xxs}
fontSize="XXS"
fontFamily="MEDIUM"
color={
item.checked
? colors.primary.paragraph
: colors.secondary.paragraph
}
style={{ textAlign: "center" }}
>
{item.title}
@@ -318,8 +329,12 @@ export const Items = ({
[
colors.error.icon,
colors.primary.accent,
colors.primary.border,
colors.primary.icon,
colors.primary.paragraph,
colors.primary.shade,
colors.secondary.background,
colors.secondary.icon,
colors.secondary.paragraph,
colors.static.orange,
columnItemWidth,
topBarSorting
@@ -349,16 +364,18 @@ export const Items = ({
autoplay={false}
showPagination
paginationStyleItemActive={{
borderRadius: 2,
backgroundColor: colors.selected.background,
height: 6,
marginHorizontal: 2
borderRadius: 6,
backgroundColor: colors.selected.accent,
height: 5,
width: 20,
marginHorizontal: 3
}}
paginationStyleItemInactive={{
borderRadius: 2,
borderRadius: 6,
backgroundColor: colors.secondary.background,
height: 6,
marginHorizontal: 2
height: 5,
width: 14,
marginHorizontal: 3
}}
paginationStyle={{
position: "relative",
@@ -377,7 +394,7 @@ export const Items = ({
style={{
flexDirection: "row",
paddingHorizontal: DefaultAppStyles.GAP,
gap: 5,
gap: Spacing.LEVEL_2,
width: width
}}
>
@@ -391,7 +408,7 @@ export const Items = ({
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: 5,
gap: Spacing.LEVEL_1,
paddingHorizontal: DefaultAppStyles.GAP
}}
>

View File

@@ -17,18 +17,17 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useState } from "react";
import { View } from "react-native";
import { db } from "../../common/database";
import ManageTags from "../../screens/manage-tags";
import { TaggedNotes } from "../../screens/notes/tagged";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { sleep } from "../../utils/time";
import { Button } from "../ui/button";
import { ColorTags } from "./color-tags";
import { Spacing } from "../../common/design/spacing";
export const Tags = ({ item, close }) => {
const { colors } = useThemeColors();
@@ -37,32 +36,12 @@ export const Tags = ({ item, close }) => {
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP,
alignSelf: "center",
justifyContent: "space-between",
width: "100%"
width: "100%",
borderTopWidth: 1,
borderColor: colors.primary.border,
paddingVertical: Spacing.LEVEL_3
}}
>
<Button
onPress={async () => {
ManageTags.present([item.id]);
close();
}}
buttonType={{
text: colors.primary.accent
}}
title={strings.addTag()}
type="secondary"
icon="plus"
iconPosition="right"
fontSize={AppFontSize.xs}
style={{
paddingHorizontal: DefaultAppStyles.GAP_SMALL,
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
}}
/>
<ColorTags item={item} />
</View>
) : null;

View File

@@ -34,6 +34,8 @@ import SheetWrapper from "../ui/sheet";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { DefaultAppStyles } from "../../utils/styles";
import { Radius, Spacing } from "../../common/design/spacing";
import AppIcon from "../ui/AppIcon";
const SheetProvider = ({ context = "global" }) => {
const { colors } = useThemeColors();
const [visible, setVisible] = useState(false);
@@ -113,43 +115,78 @@ const SheetProvider = ({ context = "global" }) => {
: data.enableGesturesInScrollView
}
>
<View
style={{
justifyContent: "center",
alignItems: "center",
marginBottom:
!data.progress && !data.icon && !data.title && !data.paragraph
? 0
: 10,
paddingHorizontal: DefaultAppStyles.GAP
}}
>
{data?.progress ? (
<ActivityIndicator
{data?.progress || data?.icon || data?.paragraph ? (
<View
style={{
justifyContent: "center",
alignItems: "center",
marginBottom:
!data.progress && !data.icon && !data.title && !data.paragraph
? 0
: 10,
paddingHorizontal: DefaultAppStyles.GAP
}}
>
<View
style={{
marginTop: DefaultAppStyles.GAP
flexDirection: "row",
alignItems: "flex-start",
gap: Spacing.LEVEL_1,
paddingTop: Spacing.LEVEL_2
}}
size={50}
color={colors.primary.accent}
/>
) : null}
>
{data?.icon || data?.progress ? (
<View
style={{
width: 32,
height: 32,
borderRadius: Radius.XS,
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.secondary.background
}}
>
{data.icon ? (
<AppIcon
name="clock"
iconFamily="notesnook"
size={16}
color={colors.primary.icon}
/>
) : (
<ActivityIndicator
style={{
marginTop: DefaultAppStyles.GAP
}}
size={50}
color={colors.primary.accent}
/>
)}
</View>
) : null}
{data?.icon ? (
<Icon
color={colors[data.iconColor] || colors.primary.accent}
name={data.icon}
size={50}
/>
) : null}
{data?.title ? <Heading> {data?.title}</Heading> : null}
{data?.paragraph ? (
<Paragraph style={{ textAlign: "center" }}>
{data?.paragraph}
</Paragraph>
) : null}
</View>
{data?.title || data?.paragraph ? (
<View
style={{
flex: 1,
gap: Spacing.LEVEL_1
}}
>
{data.title ? (
<Heading fontSize="XL" lineHeight="100%">
{data.title}
</Heading>
) : null}
{data.paragraph ? (
<Paragraph fontSize="SM" color={colors.secondary.paragraph}>
{data.paragraph}
</Paragraph>
) : null}
</View>
) : null}
</View>
</View>
) : null}
{typeof data.component === "function"
? data.component(
@@ -212,20 +249,27 @@ const SheetProvider = ({ context = "global" }) => {
/>
) : null}
{data?.actionsArray &&
data?.actionsArray.map((item) => (
<Button
onPress={item.action}
key={item.accentText}
title={item.actionText}
icon={item.icon && item.icon}
type={item.type || "accent"}
style={{
marginBottom: DefaultAppStyles.GAP_VERTICAL
}}
width="100%"
/>
))}
<View
style={{
flexDirection: "row",
gap: Spacing.LEVEL_2
}}
>
{data?.actionsArray &&
data?.actionsArray.map((item) => (
<Button
onPress={item.action}
key={item.accentText}
title={item.actionText}
icon={item.icon && item.icon}
type={item.type || "accent"}
style={{
flex: 1,
flexShrink: 1
}}
/>
))}
</View>
{data?.learnMore ? (
<Paragraph

View File

@@ -0,0 +1,213 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useRef, useState } from "react";
import { View } from "react-native";
import { ScrollView } from "react-native-actions-sheet";
import { Radius, Spacing } from "../../../common/design/spacing";
import { db } from "../../../common/database";
import { ToastManager, presentSheet } from "../../../services/event-manager";
import { DefaultAppStyles } from "../../../utils/styles";
import AppIcon from "../../ui/AppIcon";
import { Button } from "../../ui/button";
import FormInput, {
createFormRef,
validators
} from "../../ui/input/form-input";
import { Pressable } from "../../ui/pressable";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
const getExpiryOptions = () => [
{ label: strings.expiryOneDay(), value: 24 * 60 * 60 * 1000 },
{ label: strings.expiryOneWeek(), value: 7 * 24 * 60 * 60 * 1000 },
{ label: strings.expiryOneMonth(), value: 30 * 24 * 60 * 60 * 1000 },
{ label: strings.expiryOneYear(), value: 365 * 24 * 60 * 60 * 1000 },
{ label: strings.never(), value: -1 }
];
type AddApiKeySheetProps = {
close?: (ctx?: string | undefined) => void;
onAdd: () => void;
};
export default function AddApiKeySheet({ close, onAdd }: AddApiKeySheetProps) {
const { colors } = useThemeColors();
const formRef = useRef(
createFormRef({
keyName: ""
})
);
const [selectedExpiry, setSelectedExpiry] = useState(
getExpiryOptions()[2].value
);
const [isCreating, setIsCreating] = useState(false);
const handleCreate = async () => {
try {
if (formRef.current.validateField("keyName")) {
return;
}
const keyName = formRef.current.getValue("keyName").trim();
setIsCreating(true);
await db.inboxApiKeys.create(keyName, selectedExpiry);
ToastManager.show({
message: strings.apiKeyCreatedSuccessfully(),
type: "success"
});
onAdd();
close?.();
} catch (error) {
const message = error instanceof Error ? error.message : "";
ToastManager.show({
message: strings.failedToCreateApiKey(message),
type: "error"
});
formRef.current.setError(
"keyName",
message || strings.failedToCreateApiKey("")
);
} finally {
setIsCreating(false);
}
};
return (
<ScrollView
contentContainerStyle={{
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP,
paddingTop: DefaultAppStyles.GAP_VERTICAL,
paddingBottom: DefaultAppStyles.GAP_VERTICAL * 2
}}
>
<View
style={{
flexDirection: "row",
alignItems: "flex-start",
gap: DefaultAppStyles.GAP_SMALL
}}
>
<View
style={{
width: 32,
height: 32,
borderRadius: Radius.XS,
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.secondary.background
}}
>
<AppIcon
name="key"
iconFamily="notesnook"
size={16}
color={colors.primary.icon}
/>
</View>
<View style={{ flex: 1, gap: DefaultAppStyles.GAP_VERTICAL_SMALL }}>
<Heading fontSize="XL" lineHeight="100%">
{strings.createApiKey()}
</Heading>
<Paragraph fontSize="SM" color={colors.secondary.paragraph}>
{strings.createApiKeyDesc()}
</Paragraph>
</View>
</View>
<FormInput
name="keyName"
formRef={formRef}
label={strings.keyName()}
placeholder={strings.exampleKeyName()}
validators={[validators.required(strings.enterKeyName())]}
containerStyle={{ borderRadius: Radius.XS }}
onChangeText={() => {
formRef.current.setError("keyName", undefined);
}}
onSubmitEditing={handleCreate}
/>
<View style={{ height: 1, backgroundColor: colors.primary.border }} />
<View style={{ gap: DefaultAppStyles.GAP_VERTICAL }}>
<Heading fontSize="MD" lineHeight="100%">
{strings.expiresIn()}
</Heading>
<View style={{ gap: DefaultAppStyles.GAP_VERTICAL }}>
{getExpiryOptions().map((option) => {
const selected = selectedExpiry === option.value;
return (
<Pressable
key={option.label}
onPress={() => setSelectedExpiry(option.value)}
type={selected ? "selected" : "transparent"}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
padding: Spacing.LEVEL_2,
borderRadius: Radius.XS,
borderWidth: selected ? 0 : 1,
borderColor: colors.secondary.border
}}
>
<Heading
fontFamily="MEDIUM"
fontSize="SM"
lineHeight="100%"
color={
selected ? colors.selected.heading : colors.secondary.heading
}
>
{option.label}
</Heading>
<AppIcon
name={selected ? "radiobox-marked" : "radiobox-blank"}
size={16}
color={selected ? colors.selected.accent : colors.secondary.icon}
/>
</Pressable>
);
})}
</View>
</View>
<Button
title={isCreating ? strings.creating() : strings.create()}
type="accent"
width="100%"
loading={isCreating}
disabled={isCreating}
onPress={handleCreate}
/>
</ScrollView>
);
}
AddApiKeySheet.present = (onAdd: () => void) => {
presentSheet({
component: (ref, close, _update) => (
<AddApiKeySheet close={close} onAdd={onAdd} />
)
});
};

View File

@@ -31,15 +31,18 @@ import { useNotebookStore } from "../../../stores/use-notebook-store";
import { useRelationStore } from "../../../stores/use-relation-store";
import { eOnNotebookUpdated } from "../../../utils/events";
import { getParentNotebookId } from "../../../utils/notebooks";
import Input from "../../ui/input";
import { useSettingStore } from "../../../stores/use-setting-store";
import DialogHeader from "../../dialog/dialog-header";
import DialogButtons from "../../dialog/dialog-buttons";
import { presentDialog } from "../../dialog/functions";
import { getElevationStyle } from "../../../utils/elevation";
import { defaultBorderRadius } from "../../../utils/size";
import { useThemeColors } from "@notesnook/theme";
import { getContainerBorder } from "../../../utils/colors";
import { Radius, Spacing } from "../../../common/design/spacing";
import FormInput, {
createFormRef,
validators
} from "../../ui/input/form-input";
export const AddNotebookSheet = ({
notebook,
@@ -55,29 +58,28 @@ export const AddNotebookSheet = ({
defaultTitle?: string;
}) => {
const { colors } = useThemeColors();
const title = useRef(notebook?.title || defaultTitle);
const description = useRef(notebook?.description);
const titleInput = useRef<TextInput>(null);
const descriptionInput = useRef<TextInput>(null);
const [loading, setLoading] = useState(false);
const formRef = useRef(
createFormRef({
title: notebook?.title || defaultTitle || "",
description: notebook?.description || ""
})
);
const onSaveChanges = async () => {
if (loading) return;
if (!title.current || title?.current.trim().length === 0) {
ToastManager.show({
heading: strings.enterNotebookTitle(),
type: "error",
context: "local"
});
return;
}
if (!formRef.current?.validate()) return;
setLoading(true);
const values = formRef.current.getValues();
const id = await db.notebooks.add({
title: title.current,
description: description.current,
title: values.title,
description: values.description,
id: notebook?.id
});
@@ -127,57 +129,67 @@ export const AddNotebookSheet = ({
...getElevationStyle(5),
width: DDS.isTab ? 400 : "85%",
maxHeight: 450,
borderRadius: defaultBorderRadius,
borderRadius: Radius.LG,
backgroundColor: colors.primary.background,
paddingTop: 12,
gap: Spacing.LEVEL_4,
paddingVertical: Spacing.LEVEL_4,
...getContainerBorder(colors.primary.border, 0.5),
overflow: "hidden"
}}
>
<View
style={{
paddingHorizontal: 12
paddingHorizontal: Spacing.LEVEL_3,
gap: Spacing.LEVEL_4
}}
>
<DialogHeader
title={notebook ? strings.editNotebook() : strings.newNotebook()}
/>
<Input
fwdRef={titleInput}
testID={notesnook.ids.dialogs.notebook.inputs.title}
onChangeText={(value) => {
title.current = value;
<View
style={{
gap: Spacing.LEVEL_2
}}
onLayout={() => {
setTimeout(() => {
titleInput?.current?.focus();
}, 300);
}}
placeholder={strings.enterNotebookTitle()}
onSubmit={() => {
descriptionInput.current?.focus();
}}
returnKeyLabel="Next"
returnKeyType="next"
defaultValue={notebook ? notebook.title : title.current}
/>
>
<FormInput
fwdRef={titleInput}
name="title"
formRef={formRef}
testID={notesnook.ids.dialogs.notebook.inputs.title}
onLayout={() => {
setTimeout(() => {
titleInput?.current?.focus();
}, 300);
}}
validators={[validators.required(strings.titleIsRequired())]}
placeholder={"eg. My Notebook"}
onSubmitEditing={() => {
descriptionInput.current?.focus();
}}
label={strings.enterNotebookTitle()}
returnKeyLabel="Next"
returnKeyType="next"
defaultValue={
notebook ? notebook.title : formRef.current.getValue("title")
}
/>
<Input
fwdRef={descriptionInput}
testID={notesnook.ids.dialogs.notebook.inputs.description}
onChangeText={(value) => {
description.current = value;
}}
placeholder={strings.enterNotebookDescription()}
returnKeyLabel={strings.next()}
returnKeyType="next"
defaultValue={notebook ? notebook.description : ""}
/>
<FormInput
fwdRef={descriptionInput}
name="description"
formRef={formRef}
testID={notesnook.ids.dialogs.notebook.inputs.description}
label={strings.enterNotebookDescription()}
placeholder={"eg. This is My Notebook"}
returnKeyLabel={strings.next()}
returnKeyType="next"
defaultValue={notebook ? notebook.description : ""}
/>
</View>
</View>
<DialogButtons
onPressNegative={() => {
title.current = undefined;
close?.(false);
useSettingStore.getState().setSheetKeyboardHandler(true);
}}

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