Compare commits

..

151 Commits

Author SHA1 Message Date
01zulfi
ca4368a08e web: bump version to 3.3.23
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-08 10:43:42 +05:00
Ammar Ahmed
537c514562 Merge pull request #9953 from streetwriters/mobile/release-3.3.25
Mobile/release 3.3.25
2026-06-08 10:23:15 +05:00
copilot-swe-agent[bot]
d9720ba7b8 ci: make iOS artifact upload non-blocking 2026-06-06 10:11:35 +00:00
Ammar Ahmed
16e968dc57 mobile: release v3.3.25 2026-06-06 10:11:50 +05:00
Ammar Ahmed
03fafcbd15 mobile: fix attachment file-size check 2026-06-06 10:11:25 +05:00
Abdullah Atta
b7bd06c9f3 Release v3.3.22 (#9952)
* web: fix file size is 0 error on attachment upload (#9951)

* web: bump version to 3.3.22
2026-06-06 09:48:03 +05:00
Abdullah Atta
766a8f28d4 web: bump version to 3.3.22 2026-06-06 09:22:20 +05:00
Abdullah Atta
86512b5ce7 web: fix file size is 0 error on attachment upload (#9951) 2026-06-06 09:10:29 +05:00
Ammar Ahmed
a820fcde53 editor: allow any width/height value in EmbedSizeOptions when inserting embed (#9925)
* editor: allow any width/height value in EmbedSizeOptions when inserting embeds

* editor: only accept valid url as value

* editor: allow embeding normal website urls

* editor: check url if embedSource=url

* editor: use defaultValue prop for width/height
2026-06-04 13:16:51 +05:00
01zulfi
5ddf1eddf5 web: validate max date when creating reminder (#9937)
* web: validate max date when creating reminder
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: skip max date validation on repeat mode in reminder dialog
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-04 13:16:01 +05:00
Asad Ali
4237d2eb4a desktop: fix spell check issue for non-latin languages #9423 (#9443)
* desktop: fix spell check issue for non-latin languages #9423

Signed-off-by: Asad Ali <asadali.wolf@gmail.com>

* Delete apps/desktop/src/api/spell-checker.test.ts

Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>

* desktop: refactor

---------

Signed-off-by: Asad Ali <asadali.wolf@gmail.com>
Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>
Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-06-04 09:36:34 +05:00
Ammar Ahmed
f67cd503d6 Merge pull request #9922 from streetwriters/fix/400
mobile: hide toc button if no headings in editor
2026-06-04 08:54:08 +05:00
Ammar Ahmed
14039d6a1e Merge pull request #9921 from streetwriters/fix/401
Fix long tag name does not wrap properly in editor
2026-06-04 08:53:50 +05:00
Ammar Ahmed
d1fed9072e Merge pull request #9890 from kashaf-ansari-dev/fix-duplicate-note-message
mobile: add success message after note duplication
2026-06-04 08:52:31 +05:00
kashaf-ansari-dev
b003a3ffed mobile: reduce translation diff noise
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-03 14:17:33 +05:00
kashaf-ansari-dev
55e10e4755 mobile: add success message after note duplication
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-03 14:14:07 +05:00
Ammar Ahmed
7c1dd0f79a Merge pull request #9915 from streetwriters/fix/359-360
mobile: fix unable to edit/delete reminder when accessing reminders from note properties
2026-06-03 14:03:41 +05:00
Ammar Ahmed
229cf195a8 Merge pull request #9920 from streetwriters/fix/357
Fix screen jerks when opening add reminder screen from note
2026-06-03 14:02:33 +05:00
Ammar Ahmed
38da6ac08b Merge pull request #9918 from streetwriters/fix/318
Fix themes do not load properly on mobile in theme selector
2026-06-03 14:01:58 +05:00
Ammar Ahmed
597dc587cd Merge pull request #9919 from streetwriters/fix/317
Fix subscription not active opens a sheet that keeps loading if there is an error
2026-06-03 14:01:32 +05:00
Ammar Ahmed
5c76b92789 Merge pull request #9893 from kashaf-ansari-dev/fix-publish-password-validation
mobile: validate password field before publishing note
2026-06-03 14:01:09 +05:00
Abdullah Atta
8418d5fa9c Merge pull request #9886 from streetwriters/web/notebook-tag-group-options
web: allow notebook/tag/color level group&sort preferences
2026-06-03 13:35:06 +05:00
01zulfi
cd071f8f58 web: ensure full year range is visible in reminder dialog's day picker (#9928)
* web: ensure full year range is visible in reminder dialog's day picker
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: include boundary years in day picker's fluid year dropdown
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-03 11:52:36 +05:00
01zulfi
32ddc3fa40 web: refresh tag & notebook filtered lists on item rename (#9923)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-02 15:27:47 +05:00
01zulfi
68d07a7862 web: fix properties panel in focus mode (#9917)
* allow properties panel to open/close when app is in focus mode

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-02 13:06:50 +05:00
01zulfi
2aa5162fc9 web: set edited date as max date in note creation date edit's day picker (#9924)
* web: set edited date as max date in note creation date edit's day picker
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: fix creation date not being able to set to same as edited date
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-02 13:02:06 +05:00
01zulfi
cf5565d09d web: don't allow note expiry to be set beyond 1 year in the future (#9901)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-02 08:56:21 +05:00
Ammar Ahmed
f129e0c411 mobile: hide toc button if no headings in note 2026-06-01 13:18:13 +05:00
01zulfi
bb43d80e05 Merge pull request #9916 from streetwriters/release/3.3.21
web: bump version to 3.3.21
2026-06-01 12:52:57 +05:00
Ammar Ahmed
4e4d3866df mobile: limit tag to single line only 2026-06-01 12:43:41 +05:00
Ammar Ahmed
b2088ef782 mobile: fix long tag name does not wrap properly in editor 2026-06-01 12:27:37 +05:00
Ammar Ahmed
07db14f6bf mobile: fix screen jerks when opening add reminder screen from note 2026-06-01 11:53:00 +05:00
Ammar Ahmed
82d37701c5 mobile: fix gap in buttons 2026-06-01 11:09:29 +05:00
Ammar Ahmed
b48ab3c85d mobile: fix query error on fetching themes in theme-selector 2026-06-01 10:56:35 +05:00
01zulfi
52d7d90b68 web: bump version to 3.3.21
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-01 10:21:57 +05:00
Ammar Ahmed
bb7ff4f376 mobile: show error if no active subscription is found 2026-06-01 10:13:31 +05:00
Ammar Ahmed
43b35917ef mobile: fix subscription not active screen gets stuck on loading on iOS 2026-06-01 09:32:05 +05:00
Ammar Ahmed
bcfa34ca05 mobile: fix unable to edit/delete reminder when accessing reminders from note properties 2026-06-01 08:40:30 +05:00
Abdullah Atta
aab6a36067 Do not refetch user before decrypting each chunk in sync (#9805)
* core: do not refetch user before decrypting each chunk in sync

* web: fix editor readonly state not updating in realtime
2026-05-25 19:42:03 +05:00
01zulfi
c7e0cd35e7 editor: expand collapsed nodes on search scroll if search text inside callout (#9745)
* editor: expand callout on search scroll if search text inside callout
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* editor: expand outline list && headings on search scroll
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* editor: expand parents & scroll into view on type

* editor: fix parents expanding on every transaction

* editor: add a small debounce to delay search

* editor: fix editor not scrolling to selected search result

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-05-25 19:41:06 +05:00
01zulfi
bd3edc535a web: use getGroupOptionsById & setGroupOptionsById
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-25 15:04:17 +05:00
01zulfi
1dff7a121d core: fix group options by id tests
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-25 14:50:57 +05:00
Ammar Ahmed
3f3467e3a7 mobile: add support for sorting/grouping items based on notebook/tag/color id 2026-05-25 13:21:48 +05:00
Ammar Ahmed
39bb9afa0e core: add getGroupOptionsById/setGroupOptionsById 2026-05-25 13:21:00 +05:00
kashaf-ansari-dev
886e757715 mobile: removed redundant isLocked check for password field in publish form
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-05-25 11:21:55 +05:00
Ammar Ahmed
f1124c4112 Merge pull request #9896 from streetwriters/ci-fix-ios-workflow
ci: fix ios workflow
2026-05-25 10:18:54 +05:00
Ammar Ahmed
a1575ab5ea ci: fix 2026-05-25 10:18:16 +05:00
Ammar Ahmed
d31eaef6d4 ci: update 2026-05-25 10:17:22 +05:00
Ammar Ahmed
930e31d109 ci: update 2026-05-25 08:44:08 +05:00
Ammar Ahmed
2a6cc90592 ci: fix missing ios platform error 2026-05-25 08:43:46 +05:00
Ammar Ahmed
a13962f8c9 ci: fix ios workflow 2026-05-23 13:49:15 +05:00
kashaf-ansari-dev
5c93645969 mobile: migrate publish inputs to FormInput with validation
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-05-23 11:03:00 +05:00
01zulfi
a4c8259a02 web: bump version to 3.3.20 (#9888)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-22 20:28:04 +05:00
kashaf-ansari-dev
07defdfa78 mobile: validate password field before publishing note
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-05-22 17:15:38 +05:00
01zulfi
fc93a39164 web: fix weird scroll when toggling switch in attach files dialog (#9891)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-22 15:46:36 +05:00
01zulfi
9b588c50bf web: fix day picker to allow selecting all months within min/max range (#9879)
* web: fix day picker to allow selecting all months within min/max range
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: disable month nav buttons if they exceed min/max range in DayPicker
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-22 11:07:21 +05:00
01zulfi
27b00d4ef9 web: allow notebook/tag level group&sort preferences
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-22 10:48:27 +05:00
Ammar Ahmed
91e0437554 Merge pull request #9872 from streetwriters/mobile/release-3.3.24
mobile: release v3.3.24
2026-05-20 09:46:10 +05:00
01zulfi
4dcf14fce1 core: handle unknown collection types in sync (#9878)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-19 13:38:20 +05:00
01zulfi
8f8ac25d20 desktop: add support for opening file links (#9784)
* desktop: add support for opening file links
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: unescape paths with spaces

* web: wrap message in confirm dialog
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* desktop: show message box if path doesn't exist when opening file link
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* desktop: disable opening file links on flatpak
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* desktop: wrap long file paths in electron dialog
* on linux, long messages in dialog aren't wrapped, so we need to wrap manually

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* desktop: fix persistent "path not found" error

* intl: update strings

---------

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-05-19 13:27:46 +05:00
01zulfi
b594d58d31 web: improve attachments UX (#9718)
* web: improve attaching attachments UX
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: don't couple editor logic in attach files dialog
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: close attach file dialog after single file && remove countdown
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: show file size exceeded message in attach file dialog
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: add 'processing' status message when file picker is opened
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: show "processing" modal after picking files

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-05-19 13:25:17 +05:00
01zulfi
20921df3f1 Merge pull request #9869 from streetwriters/release/3.3.19
web: bump version to 3.3.19
2026-05-18 15:23:49 +05:00
01zulfi
43d3036525 web: fix subscription settings not showing
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-18 13:19:33 +05:00
Ammar Ahmed
f725499039 mobile: release 3.3.24 2026-05-18 12:47:36 +05:00
01zulfi
cd743a92cb web: bump version to 3.3.19
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-18 10:31:21 +05:00
Abdullah Atta
ea0550cb05 desktop: disable updates for portable desktop app (#9855)
* desktop: disable updates for portable desktop app

* web: hide setting group if all settings under it are hidden

* web: hide check for updates button on flatpak/snap/portable apps

* desktop: disable auto updates for flatpak/snap/portable apps
2026-05-15 18:59:01 +05:00
Ammar Ahmed
d9d5cb2168 Merge pull request #9832 from streetwriters/fix/310
mobile: fix form validation in set your name dialog
2026-05-15 08:42:32 +05:00
Ammar Ahmed
69e509da1c Merge pull request #9857 from streetwriters/fix/crash-on-upgrade
mobile: fix app crashes on upgrade from v2.6.x to v3
2026-05-15 08:41:43 +05:00
Ammar Ahmed
6ba1fda2f2 mobile: fix app crashes on upgrade from v2.6.x to v3 2026-05-14 13:43:20 +05:00
Ammar Ahmed
93efecd6c3 Merge pull request #9839 from streetwriters/mobile/release-3.3.23
mobile: release 3.3.23
2026-05-14 10:42:25 +05:00
Ammar Ahmed
70d1119cf0 mobile: fix crash on tap set your name 2026-05-14 09:39:01 +05:00
Ammar Ahmed
3f11b76811 mobile: fix form validation in set your name dialog 2026-05-14 09:38:15 +05:00
01zulfi
d912c93f81 web: fix validation error message on add notebook dialog (#9852)
* only title is required field

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-14 09:25:21 +05:00
01zulfi
e635bf628a web: fix expiring notes option on free plan (#9853)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-14 09:25:13 +05:00
01zulfi
8048c90a89 web: disable months/year menu options where applicable (#9846)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-13 11:46:23 +05:00
01zulfi
8093f44a70 web: skip attachments restore from backup if user not logged in (#9778)
* web: skip attachments restore from backup if user not logged in
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: show login message when downloading attachments without login
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: show backup restored success toast
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-12 09:08:26 +05:00
Ammar Ahmed
d0bfc21b49 mobile: release 3.3.23 2026-05-11 14:04:02 +05:00
01zulfi
f67f90cecd Merge pull request #9838 from streetwriters/release/3.3.18
web: bump version to 3.3.18
2026-05-11 13:47:06 +05:00
Ammar Ahmed
ff4e2631cb Merge pull request #9828 from streetwriters/fix/313
Add input validation for 2fa method configuration
2026-05-11 13:38:44 +05:00
Ammar Ahmed
9779685102 mobile: fix no success message on copying 2fa codes 2026-05-11 13:38:19 +05:00
Ammar Ahmed
f2925725b8 mobile: add input validation for 2fa method configuration 2026-05-11 13:38:17 +05:00
Ammar Ahmed
9ca9fe3f99 Merge pull request #9827 from streetwriters/fix/312
Fix copy button text is cut in 2fa settings page
2026-05-11 13:36:49 +05:00
Ammar Ahmed
8e5039fa7f Merge pull request #9831 from streetwriters/fix/289
Add form validation and error handling in change email on Mobile
2026-05-11 13:36:15 +05:00
Ammar Ahmed
2f47fe331d Merge pull request #9825 from streetwriters/fix/288
Add form validation in change password screen
2026-05-11 13:35:45 +05:00
Ammar Ahmed
238abee126 mobile: add form validation in change password screen 2026-05-11 13:35:28 +05:00
Ammar Ahmed
a8d0fa3b94 Merge pull request #9820 from streetwriters/fix/286
Fix rename attachment does not work on iOS
2026-05-11 13:33:00 +05:00
Ammar Ahmed
27b3cfcd23 Merge pull request #9819 from streetwriters/fix/285
Fix Reminder allows past time in edit mode without validation error
2026-05-11 13:32:39 +05:00
01zulfi
d3f1690dca web: bump version to 3.3.18
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-11 10:19:20 +05:00
Ammar Ahmed
d4d7eac240 mobile: fix loading indicator causes cancel button to jump 2026-05-08 10:44:05 +05:00
Ammar Ahmed
e7fb246c25 mobile: show errors under input in rename dialog 2026-05-08 10:44:04 +05:00
Ammar Ahmed
03defb3a48 mobile: fix defaultValue is used if input is empty in dialog input 2026-05-08 10:42:25 +05:00
Ammar Ahmed
73c44d476b mobile: fix rename attachment does not work on iOS 2026-05-08 10:41:53 +05:00
Ammar Ahmed
61aaaba452 mobile: cleanup 2026-05-08 10:02:57 +05:00
Ammar Ahmed
9992bf8c32 mobile: add form validation in change email 2026-05-08 09:26:11 +05:00
Ammar Ahmed
b8b7d37799 core: remap Invalid token error to Invalid code in changeEmail 2026-05-08 09:24:39 +05:00
Abdullah Atta
0aaabfab62 web: exclude hidden settings from setting search (#9772)
* web: exclude hidden settings from search

* web: add support for searching setting section groups
2026-05-08 08:53:00 +05:00
Ammar Ahmed
14d680c092 mobile: show all errors inline with inputs/data-entry items 2026-05-07 18:42:47 +05:00
Ammar Ahmed
28f95b0061 mobile: fix plans keep loading on paywall screen 2026-05-07 13:01:38 +05:00
Ammar Ahmed
419dd555f4 mobile: fix reminder allows past time in edit mode without validation error 2026-05-07 13:01:37 +05:00
Ammar Ahmed
1312ce7d50 Merge pull request #9818 from streetwriters/fix/284
Trim title before checking if title is valid in add reminder screen
2026-05-07 12:58:46 +05:00
Ammar Ahmed
d16cd510b3 mobile: cleanup 2026-05-07 12:58:05 +05:00
Ammar Ahmed
af08ed5174 mobile: show title is required error under the input 2026-05-07 12:58:05 +05:00
Ammar Ahmed
365e1d7029 mobile: trim title before checking if title is valid 2026-05-07 12:57:25 +05:00
Ammar Ahmed
80b75fc28c Merge pull request #9821 from streetwriters/fix/287
Show confirm dialog before deleting attachment in attachment manager
2026-05-07 12:54:10 +05:00
Ammar Ahmed
6de4b4ac2d mobile: show toast message after attachment deleted 2026-05-07 12:53:44 +05:00
Ammar Ahmed
39bfff6b14 mobile: fix base dialog background is always transparent 2026-05-07 12:53:44 +05:00
Ammar Ahmed
e381b62e5c mobile: show confirm dialog before deleting attachment in attachment manager 2026-05-07 12:53:43 +05:00
Ammar Ahmed
ffef5affce Merge pull request #9822 from streetwriters/fix/309
mobile: fix handling invalid gift code input
2026-05-07 12:28:27 +05:00
Ammar Ahmed
780276b30e mobile: fix pro user walk through makes app get stuck 2026-05-07 11:12:49 +05:00
Ammar Ahmed
e023f11cd9 mobile: fix copy button text is cut in 2fa settings page 2026-05-07 09:22:59 +05:00
Ammar Ahmed
276239dd2b mobile: add form validation support in dialog inputs 2026-05-07 08:49:21 +05:00
Ammar Ahmed
5ef3bdcad4 mobile: fix handling invalid gift code 2026-05-07 08:03:45 +05:00
Ammar Ahmed
63076d37f5 Merge pull request #9808 from streetwriters/fix/270
Improve error handling when signing up on mobile
2026-05-06 13:21:56 +05:00
Ammar Ahmed
724ad58824 mobile: show errors under the email input 2026-05-06 11:28:58 +05:00
Ammar Ahmed
7fcdb3b55d mobile: skip showing toast message 2026-05-06 08:16:35 +05:00
Ammar Ahmed
47ba40d7cd mobile: fix ts errors in build 2026-05-05 21:00:17 +05:00
Ammar Ahmed
0801e73242 mobile: fix login and signup flows 2026-05-05 19:06:42 +05:00
Ammar Ahmed
c82487983c Merge pull request #9806 from streetwriters/fix/268
Fix No error message shown for missing or incorrect 2FA code on mobile
2026-05-05 14:13:20 +05:00
Ammar Ahmed
2ad39048da mobile: fix form validation in login, signup and forgot password 2026-05-05 13:47:37 +05:00
Ammar Ahmed
3421627f31 mobile: show error if passwords do not match or fields empty 2026-05-05 10:34:44 +05:00
Ammar Ahmed
cf6262e995 mobile: fix showing 2fa error when field is empty and show error below input field 2026-05-05 10:32:27 +05:00
Ammar Ahmed
8a325bf7c0 mobile: cleanup 2026-05-05 10:11:19 +05:00
Ammar Ahmed
883667b411 mobile: show error message on failed two-factor auth 2026-05-05 10:11:19 +05:00
Ammar Ahmed
06b88e74ae Merge pull request #9807 from streetwriters/fix/269
Remove password length check on signup on mobile
2026-05-05 10:10:03 +05:00
Ammar Ahmed
7800a25775 Merge pull request #9803 from streetwriters/mobile/release-3.3.22
mobile: release v3.3.22
2026-05-05 07:46:27 +05:00
Ammar Ahmed
01d0de3909 mobile: remove password length check on signup 2026-05-04 13:42:05 +05:00
Ammar Ahmed
d61f9472c8 mobile: fix search in archived notes returns no results 2026-05-04 13:08:05 +05:00
Ammar Ahmed
2465337148 mobile: fix show results count in search 2026-05-04 13:05:54 +05:00
01zulfi
af4638ac50 Merge pull request #9802 from streetwriters/release/3.3.17
web: bump version to 3.3.17
2026-05-04 12:33:11 +05:00
Ammar Ahmed
5e6be09aab mobile: release v3.3.22 2026-05-04 10:41:36 +05:00
Ammar Ahmed
f626953e0a Merge pull request #9781 from streetwriters/fix/archive-note-time
Fix Archive screen does not show correct date/time on note based on Sorting Type
2026-05-04 10:30:02 +05:00
01zulfi
4d4bce301d web: bump version to 3.3.17
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-04 10:21:03 +05:00
Ammar Ahmed
d45b18afd6 Merge pull request #9783 from streetwriters/feat/compact-view-search
mobile: add compact-view support in search results
2026-05-04 09:51:01 +05:00
Ammar Ahmed
4f212453a6 mobile: fix sort options not working in trash 2026-05-02 11:04:11 +05:00
Ammar Ahmed
2c1b9081bf mobile: fix group type usage
Explicitly pass group type to list and sort sheet.
2026-05-02 10:49:08 +05:00
01zulfi
7c5dc1615c web: remove testimonial with broken link (#9789)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-02 08:48:34 +05:00
Ammar Ahmed
3fb07369bb mobile: fix navigating from Monographs to Search screen 2026-05-01 10:09:50 +05:00
Ammar Ahmed
9b480950da mobile: fix typo in properties key 2026-05-01 09:59:44 +05:00
01zulfi
9f20d243fa web: disable expand sidebar button on small screens (#9769)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-05-01 09:07:28 +05:00
01zulfi
13e6573342 web: show copied text when monograph link is copied (#9780)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-30 17:31:08 +05:00
Ammar Ahmed
f37869cb5a mobile: add compact-view support in search results 2026-04-30 13:55:19 +05:00
Ammar Ahmed
46d802548b mobile: fix Archive screen does not show correct date/time on note based on sorting 2026-04-30 13:27:52 +05:00
Ammar Ahmed
2bf42c207d Merge pull request #9776 from streetwriters/mobile/release-3.3.21
mobile: v3.3.21 release
2026-04-30 10:05:04 +05:00
Ammar Ahmed
65e42e5d8b mobile: prepare for v3.3.21 release 2026-04-29 16:36:09 +05:00
Ammar Ahmed
c85007f0dd Merge pull request #9705 from streetwriters/fix/screenguard
mobile: fix screenguard on mobile & privacy mode
2026-04-29 16:20:06 +05:00
Ammar Ahmed
36058145b5 mobile: disable privacy mode on user logout 2026-04-29 16:19:18 +05:00
Ammar Ahmed
a376a6b497 mobile: fix screenguard on mobile & privacy mode 2026-04-29 16:19:18 +05:00
Ammar Ahmed
cd62d51e45 Merge pull request #9752 from streetwriters/mobile/fix-locked-note-merge-conflict
mobile: fix saving incoming note in merge conflict dialog does not work
2026-04-29 16:16:38 +05:00
Abdullah Atta
3f6fcbacbc global: fix dialog on successful bug report (#9750)
* core: handle different issue report responses

* web: better handling for issue report responses

* mobile: update issue report dialog to show correct info of where issue is reported

* mobile: close sheet on clicking "done"

* web: do not include email in issue success dialog

---------

Co-authored-by: Ammar Ahmed <ammarahmed6506@gmail.com>
2026-04-29 14:19:29 +05:00
01zulfi
2a2bf67fea web: use expire verbiage in subscription subtitle when provider is gift card (#9759)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-04-29 08:45:52 +05:00
Ammar Ahmed
04f6e1068b mobile: fix saving incoming note in merge conflict dialog does not work 2026-04-23 10:43:07 +05:00
140 changed files with 4749 additions and 2526 deletions

View File

@@ -38,6 +38,11 @@ jobs:
with:
xcode-version: "26.1.1"
- name: Setup iOS Platform
run: |
xcodebuild -downloadPlatform iOS -exportPath ~/Downloads
xcodebuild -importPlatform ~/Downloads/iphonesimulator_26.1_23B86.dmg
- name: Install node modules
run: |
npm ci --ignore-scripts --prefer-offline --no-audit

View File

@@ -19,6 +19,11 @@ jobs:
with:
xcode-version: "26.1.1"
- name: Setup iOS Platform
run: |
xcodebuild -downloadPlatform iOS -exportPath ~/Downloads
xcodebuild -importPlatform ~/Downloads/iphonesimulator_26.1_23B86.dmg
- name: Install node modules
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
@@ -111,6 +116,7 @@ jobs:
api-private-key: ${{ secrets.API_KEY }}
- name: Upload Notesnook.ipa to Github
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: Notesnook.zip

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/desktop",
"version": "3.3.16",
"version": "3.3.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/desktop",
"version": "3.3.16",
"version": "3.3.23",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {

View File

@@ -2,7 +2,7 @@
"name": "@notesnook/desktop",
"productName": "Notesnook",
"description": "Your private note taking space",
"version": "3.3.16",
"version": "3.3.23",
"appAppleId": "1544027013",
"private": true,
"main": "./dist/cjs/index.js",

View File

@@ -33,16 +33,17 @@ 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 { mkdirSync, writeFileSync } from "fs";
import { existsSync, mkdirSync, writeFileSync } from "fs";
import { dirname } from "path";
import { resolvePath } from "../utils/resolve-path";
import { observable } from "@trpc/server/observable";
import { AssetManager } from "../utils/asset-manager";
import { isFlatpak, isSnap } from "../utils";
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";
const t = initTRPC.create();
@@ -60,6 +61,7 @@ const NotificationOptions = z.object({
export const osIntegrationRouter = t.router({
isFlatpak: t.procedure.query(() => isFlatpak()),
isSnap: t.procedure.query(() => isSnap()),
isPortable: t.procedure.query(() => isPortable()),
zoomFactor: t.procedure.query(() => config.zoomFactor),
setZoomFactor: t.procedure.input(z.number()).mutation(({ input: factor }) => {
@@ -191,9 +193,28 @@ export const osIntegrationRouter = t.router({
}),
openPath: t.procedure
.input(z.object({ type: z.literal("path"), link: z.string() }))
.query(({ input }) => {
.query(async ({ input }) => {
if (isFlatpak()) return;
const { type, link } = input;
if (type === "path") return shell.openPath(resolvePath(link));
if (type !== "path") return;
const resolvedPath = resolvePath(
// remove leading slash from path on windows
platform() === "win32" ? link.slice(1) : link
);
if (!existsSync(resolvedPath)) {
if (globalThis.window) {
await dialog.showMessageBox(globalThis.window, {
type: "error",
title: "Path not found",
message: `The path does not exist:\n${wrapPath(resolvedPath)}`
});
}
return;
}
await shell.openPath(resolvedPath);
}),
bringToFront: t.procedure.query(() => bringToFront()),
changeTheme: t.procedure
@@ -298,3 +319,7 @@ function toMenuItem(
}
}
}
function wrapPath(path: string, maxLineLength = 100): string {
return path.replace(new RegExp(`(.{${maxLineLength}})`, "g"), "$1\n");
}

View File

@@ -84,35 +84,55 @@ const LANGUAGES: Record<string, string> = {
type Language = { code: string; name: string };
const LANGUAGE_REDIRECT_MAP: Record<string, string> = {
es: "es-MX",
"es-419": "es-MX",
"es-ES": "es-AR"
};
export const spellCheckerRouter = t.router({
isEnabled: t.procedure.query(() => config.isSpellCheckerEnabled),
languages: t.procedure.query(
() =>
<Language[]>(
globalThis.window?.webContents.session.availableSpellCheckerLanguages.map(
(code) => ({
code,
name: LANGUAGES[code]
})
)
)
),
enabledLanguages: t.procedure.query(
() =>
<Language[]>(
globalThis.window?.webContents.session
.getSpellCheckerLanguages()
.map((code) => ({
code,
name: LANGUAGES[code]
}))
)
),
setLanguages: t.procedure
.input(z.array(z.string()))
.mutation(({ input: languages }) =>
globalThis.window?.webContents.session.setSpellCheckerLanguages(languages)
),
languages: t.procedure.query(() => {
const available =
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
[];
return <Language[]>available
.map((code) => ({
code,
name: LANGUAGES[code] || code
}))
.sort((a, b) => a.name.localeCompare(b.name));
}),
enabledLanguages: t.procedure.query(() => {
const enabled =
globalThis.window?.webContents.session.getSpellCheckerLanguages() || [];
const available =
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
[];
const resolved = enabled
.map((code) => resolveLanguage(code, available))
.filter(Boolean) as string[];
return <Language[]>resolved.map((code) => ({
code,
name: LANGUAGES[code] || code
}));
}),
setLanguages: t.procedure.input(z.array(z.string())).mutation(({ input }) => {
const available =
globalThis.window?.webContents.session.availableSpellCheckerLanguages ||
[];
const resolved = input
.map((code) => resolveLanguage(code, available))
.filter(Boolean) as string[];
globalThis.window?.webContents.session.setSpellCheckerLanguages(resolved);
}),
toggle: t.procedure
.input(z.object({ enabled: z.boolean() }))
.mutation(({ input: { enabled } }) => {
@@ -128,3 +148,16 @@ export const spellCheckerRouter = t.router({
);
})
});
function resolveLanguage(code: string, available: string[]) {
if (LANGUAGE_REDIRECT_MAP[code]) {
const working = LANGUAGE_REDIRECT_MAP[code];
return available.includes(working) ? working : code;
}
const fallback = code.split("-")[0];
return available.includes(code)
? code
: available.includes(fallback)
? fallback
: undefined;
}

View File

@@ -24,6 +24,7 @@ import type { AppUpdaterEvents } from "electron-updater/out/AppUpdater";
import { z } from "zod";
import { config } from "../utils/config";
import { app } from "electron";
import { isFlatpak, isPortable, isSnap } from "../utils";
type UpdateInfo = { version: string };
type Progress = { percent: number };
@@ -31,13 +32,15 @@ type Progress = { percent: number };
const t = initTRPC.create();
let cancellationToken: CancellationToken | undefined = undefined;
let downloadTimeout: NodeJS.Timeout | undefined = undefined;
const updatesSupported = !isFlatpak() && !isSnap() && !isPortable();
export const updaterRouter = t.router({
autoUpdates: t.procedure.query(() => config.automaticUpdates),
autoUpdates: t.procedure.query(
() => updatesSupported && config.automaticUpdates
),
releaseTrack: t.procedure.query(() => config.releaseTrack),
install: t.procedure.query(() => autoUpdater.quitAndInstall()),
download: t.procedure.query(async () => {
if (cancellationToken) return;
if (!updatesSupported || cancellationToken) return;
clearTimeout(downloadTimeout);
await new Promise<string[]>((resolve, reject) => {
downloadTimeout = setTimeout(async () => {
@@ -52,7 +55,7 @@ export const updaterRouter = t.router({
});
}),
check: t.procedure.query(async () => {
if (cancellationToken) return;
if (!updatesSupported || cancellationToken) return;
clearTimeout(downloadTimeout);
await new Promise<void>((resolve) => {
downloadTimeout = setTimeout(async () => {

View File

@@ -33,3 +33,7 @@ export function isFlatpak() {
export function isSnap() {
return process.env.SNAP !== undefined;
}
export function isPortable() {
return process.env.PORTABLE_EXECUTABLE_DIR !== undefined;
}

View File

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

View File

@@ -66,6 +66,9 @@ const App = (props: { configureMode: "note-preview" }) => {
useEffect(() => {
SettingsService.onFirstLaunch();
changeSystemBarColors();
SettingsService.setPrivacyScreen(
SettingsService.getProperty("privacyScreen")
);
setTimeout(async () => {
await Notifications.get();
if (SettingsService.get().notifNotes) {

View File

@@ -115,6 +115,10 @@ export const FileSizeResult = {
Error: -1
};
function getFileSizeFromHeaders(headers: Headers) {
return headers.get("x-object-size") || headers.get("content-length");
}
export async function getUploadedFileSize(hash: string, retry = 0) {
try {
const url = `${hosts.API_HOST}/s3?name=${hash}`;
@@ -124,24 +128,21 @@ export async function getUploadedFileSize(hash: string, retry = 0) {
headers: { Authorization: `Bearer ${token}` }
});
if (
!attachmentInfo.ok ||
attachmentInfo.headers?.get("content-length") === null
) {
const fileSize = getFileSizeFromHeaders(attachmentInfo.headers);
if (!attachmentInfo.ok || fileSize === null) {
if (retry < 3) {
DatabaseLogger.log(`Retrying file size check: ${hash}, ${retry}`);
return getUploadedFileSize(hash, retry + 1);
}
throw new Error(
`File size check failed: ${hash}, ${
attachmentInfo.status
}, ${attachmentInfo.headers?.get("content-length")}`
`File size check failed: ${hash}, ${attachmentInfo.status}, ${fileSize}`
);
}
const contentLength = parseInt(
attachmentInfo.headers?.get("content-length") as string
);
console.log(attachmentInfo.headers);
const contentLength = parseInt(fileSize as string);
return isNaN(contentLength) ? FileSizeResult.Empty : contentLength;
} catch (e) {
DatabaseLogger.error(e);
@@ -161,10 +162,10 @@ export async function checkUpload(
size === 0
? `File size is 0.`
: size === -1
? `File verification check failed.`
: expectedSize !== decryptedLength
? `File size mismatch. Expected ${size} bytes but got ${decryptedLength} bytes.`
: undefined;
? `File verification check failed.`
: expectedSize !== decryptedLength
? `File size mismatch. Expected ${size} bytes but got ${decryptedLength} bytes.`
: undefined;
if (error) throw new Error(error);
}

View File

@@ -22,7 +22,7 @@ import { Attachment, Note, VirtualizedGrouping } from "@notesnook/core";
import { useThemeColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
import React, { RefObject, useEffect, useState } from "react";
import { View } from "react-native";
import { TextInput, View } from "react-native";
import { ActionSheetRef } from "react-native-actions-sheet";
import { ScrollView } from "react-native-gesture-handler";
import { db } from "../../common/database";
@@ -59,6 +59,7 @@ import Paragraph from "../ui/typography/paragraph";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../utils/styles";
import Navigation from "../../services/navigation";
import { createFormRef, validators } from "../ui/input/form-input";
const Actions = ({
attachment,
@@ -153,50 +154,100 @@ const Actions = ({
{
name: strings.rename(),
onPress: () => {
presentDialog({
input: true,
title: strings.renameFile(),
defaultValue: attachment.filename,
positivePress: async (value) => {
if (value && value.length > 0) {
await db.attachments.add({
hash: attachment.hash,
filename: value
});
setFilename(value);
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
}
return true;
},
positiveText: strings.rename()
});
close?.();
setTimeout(() => {
presentDialog({
title: strings.renameFile(),
form: {
formRef: createFormRef({
name: attachment.filename
}),
items: [
{
name: "name",
defaultValue: attachment.filename,
placeholder: strings.enterTitle(),
ref: React.createRef<TextInput | null>(),
validators: [validators.required(strings.nameIsRequired())]
}
],
onFormSubmit: async (form) => {
try {
const value = form.getValue("name");
await db.attachments.add({
hash: attachment.hash,
filename: value
});
setFilename(value);
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
ToastManager.show({
message: `Attachment renamed to ${value}`,
type: "success"
});
return true;
} catch (e) {
form.setError("name", (e as Error).message);
return false;
}
}
},
positiveText: strings.rename()
});
}, 500);
},
icon: "form-textbox"
},
{
name: strings.delete(),
onPress: async () => {
const relations = await db.relations.to(attachment, "note").get();
await db.attachments.remove(attachment.hash, false);
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
relations
.map((relation) => relation.fromId)
.forEach(async (id) => {
useTabStore.getState().forEachNoteTab(id, async (tab) => {
const isFocused = useTabStore.getState().currentTab === tab.id;
if (isFocused) {
eSendEvent(eOnLoadNote, {
item: await db.notes.note(id),
forced: true
});
} else {
editorController.current.commands.setLoading(true, tab.id);
}
});
});
close?.();
setTimeout(() => {
presentDialog({
title: strings.deleteAttachment(),
paragraph: strings.deleteAttachmentConfirm(),
positiveText: strings.yes(),
negativeText: strings.no(),
positiveType: "errorShade",
positivePress: async () => {
try {
const relations = await db.relations
.to(attachment, "note")
.get();
await db.attachments.remove(attachment.hash, false);
ToastManager.show({
type: "success",
message: strings.attachmentDeleted()
});
setAttachments();
eSendEvent(eDBItemUpdate, attachment.id);
relations
.map((relation) => relation.fromId)
.forEach(async (id) => {
useTabStore.getState().forEachNoteTab(id, async (tab) => {
const isFocused =
useTabStore.getState().currentTab === tab.id;
if (isFocused) {
eSendEvent(eOnLoadNote, {
item: await db.notes.note(id),
forced: true
});
} else {
editorController.current.commands.setLoading(
true,
tab.id
);
}
});
});
return true;
} catch (e) {
return false;
}
}
});
}, 500);
},
icon: "delete-outline"
}

View File

@@ -18,6 +18,7 @@ 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 { db } from "../../common/database";
@@ -26,42 +27,44 @@ import { eSendEvent, 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";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import Input from "../ui/input";
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";
export const ChangePassword = () => {
const passwordInputRef = useRef<TextInput>(null);
const password = useRef<string>(undefined);
const { colors } = useThemeColors();
const formRef = useRef(
createFormRef({
oldPassword: "",
password: ""
})
);
const oldPasswordInputRef = useRef<TextInput>(null);
const oldPassword = useRef<string>(undefined);
const [error, setError] = useState(false);
const passwordInputRef = useRef<TextInput>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const user = useUserStore((state) => state.user);
const changePassword = async () => {
setError(undefined);
formRef.current.clearErrors();
if (!user?.isEmailConfirmed) {
ToastManager.show({
heading: strings.emailNotConfirmed(),
message: strings.emailNotConfirmedDesc(),
type: "error",
context: "local"
});
setError(strings.emailNotConfirmedDesc());
return;
}
if (error || !oldPassword.current || !password.current) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
type: "error",
context: "local"
});
if (!formRef.current.validate()) {
return;
}
const values = formRef.current.getValues();
setLoading(true);
try {
const result = await BackupService.run(
@@ -74,8 +77,8 @@ export const ChangePassword = () => {
}
const passwordChanged = await db.user.changePassword(
oldPassword.current,
password.current
values.oldPassword,
values.password
);
if (!passwordChanged) {
@@ -91,15 +94,15 @@ export const ChangePassword = () => {
Navigation.goBack();
eSendEvent(eOpenRecoveryKeyDialog);
} catch (e) {
const message = (e as Error).message;
setLoading(false);
ToastManager.show({
heading: strings.passwordChangeFailed(),
message: (e as Error).message,
type: "error",
context: "local"
});
if (/old password/i.test(message)) {
formRef.current.setError("oldPassword", message);
} else {
setError(message);
}
}
setLoading(false);
};
return (
@@ -110,36 +113,61 @@ export const ChangePassword = () => {
}}
>
<Dialog context="change-password-dialog" />
<Input
<FormInput
name="oldPassword"
formRef={formRef}
fwdRef={oldPasswordInputRef}
onChangeText={(value) => {
oldPassword.current = value;
}}
loading={loading}
validators={[validators.required(strings.currentPasswordRequired())]}
returnKeyLabel="Next"
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.oldPassword()}
placeholder={strings.currentPassword()}
onSubmitEditing={() => {
passwordInputRef.current?.focus();
}}
/>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passwordInputRef}
onChangeText={(value) => {
password.current = value;
}}
onErrorCheck={(e) => setError(e)}
loading={loading}
validators={[validators.required(strings.passwordRequired())]}
returnKeyLabel={strings.next()}
returnKeyType="next"
secureTextEntry
validationType="password"
autoComplete="password"
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.newPassword()}
onSubmitEditing={() => {
changePassword();
}}
/>
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error}
</Paragraph>
) : null}
<Notice text={strings.changePasswordNotice()} type="alert" />
<View style={{ height: 10 }} />

View File

@@ -27,7 +27,7 @@ import { useThemeColors } from "@notesnook/theme";
import DialogHeader from "../dialog/dialog-header";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import Input from "../ui/input";
import FormInput, { createFormRef, validators } from "../ui/input/form-input";
import Seperator from "../ui/seperator";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
@@ -36,21 +36,22 @@ import { DefaultAppStyles } from "../../utils/styles";
export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
const { colors } = useThemeColors("sheet");
const email = useRef<string>(userEmail);
const formRef = useRef(
createFormRef({
email: userEmail || ""
})
);
const emailInputRef = useRef<TextInput>(null);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
const sendRecoveryEmail = async () => {
if (!email.current || error) {
ToastManager.show({
heading: strings.emailRequired(),
type: "error",
context: "local"
});
if (formRef.current.validateField("email")) {
return;
}
const values = formRef.current.getValues();
setLoading(true);
try {
const lastRecoveryEmailTime = SettingsService.get().lastRecoveryEmailTime;
@@ -60,7 +61,7 @@ export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
) {
throw new Error(strings.pleaseWaitBeforeSendEmail());
}
await db.user.recoverAccount(email.current.toLowerCase());
await db.user.recoverAccount(values.email.toLowerCase());
SettingsService.set({
lastRecoveryEmailTime: Date.now()
});
@@ -75,12 +76,7 @@ export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
setSent(true);
} catch (e) {
setLoading(false);
ToastManager.show({
heading: strings.recoveryEmailFailed(),
message: (e as Error).message,
type: "error",
context: "local"
});
formRef.current.setError("email", (e as Error).message);
}
};
@@ -126,22 +122,25 @@ export const ForgotPassword = ({ userEmail }: { userEmail: string }) => {
<DialogHeader title={strings.accountRecovery()} />
<Seperator />
<Input
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
onChangeText={(value) => {
email.current = value;
}}
defaultValue={email.current}
onErrorCheck={(e) => setError(e)}
loading={loading}
returnKeyLabel={strings.next()}
returnKeyType="next"
autoComplete="email"
validationType="email"
keyboardType="email-address"
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
onSubmit={() => {}}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
sendRecoveryEmail();
}}
/>
<Button

View File

@@ -37,8 +37,9 @@ import { DefaultAppStyles } from "../../utils/styles";
import { sleep } from "../../utils/time";
import { Dialog } from "../dialog";
import { Progress } from "../sheets/progress";
import AppIcon from "../ui/AppIcon";
import { Button } from "../ui/button";
import Input from "../ui/input";
import FormInput, { validators } from "../ui/input/form-input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { hideAuth } from "./common";
@@ -63,14 +64,13 @@ export const Login = ({
const {
step,
setStep,
password,
email,
emailInputRef,
passwordInputRef,
loading,
setLoading,
setError,
login
login,
error,
formRef
} = useLogin(async () => {
eSendEvent(eUserLoggedIn, true);
await sleep(500);
@@ -93,6 +93,11 @@ export const Login = ({
});
const { width, height } = useWindowDimensions();
const isTablet = width > 600;
const onContinue = () => {
login();
};
useEffect(() => {
async () => {
setStep(LoginSteps.emailAuth);
@@ -198,27 +203,27 @@ export const Login = ({
gap: DefaultAppStyles.GAP_VERTICAL
}}
>
<Input
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
onChangeText={(value) => {
email.current = value;
}}
testID="input.email"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
validationType="email"
keyboardType="email-address"
marginBottom={0}
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
defaultValue={email.current}
editable={step === LoginSteps.emailAuth && !loading}
onSubmit={() => {
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
if (step === LoginSteps.emailAuth) {
login();
onContinue();
} else {
passwordInputRef.current?.focus();
}
@@ -227,11 +232,10 @@ export const Login = ({
{step === LoginSteps.passwordAuth && (
<>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passwordInputRef}
onChangeText={(value) => {
password.current = value;
}}
testID="input.password"
returnKeyLabel={strings.done()}
returnKeyType="done"
@@ -242,9 +246,9 @@ export const Login = ({
placeholder={strings.password()}
marginBottom={0}
editable={!loading}
defaultValue={password.current}
onSubmit={() => {
login();
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={() => {
onContinue();
}}
/>
<Button
@@ -255,9 +259,13 @@ export const Login = ({
paddingHorizontal: 0
}}
onPress={() => {
if (loading || !email.current) return;
if (loading) return;
presentSheet({
component: <ForgotPassword userEmail={email.current} />
component: (
<ForgotPassword
userEmail={formRef.current.getValue("email")}
/>
)
});
}}
textStyle={{
@@ -273,8 +281,7 @@ export const Login = ({
<Button
loading={loading}
onPress={() => {
if (loading) return;
login();
onContinue();
}}
style={{
width: "100%"
@@ -328,6 +335,25 @@ export const Login = ({
</Paragraph>
</TouchableOpacity>
) : null}
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error.message}
</Paragraph>
) : null}
</View>
</View>
</View>

View File

@@ -43,29 +43,26 @@ import SheetProvider from "../sheet-provider";
import { Toast } from "../toast";
import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import Input from "../ui/input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { LoginSteps, 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";
export const SessionExpired = () => {
const { colors } = useThemeColors();
const [visible, setVisible] = useState(false);
const [focused, setFocused] = useState(false);
const { step, password, email, passwordInputRef, loading, login } = useLogin(
() => {
eSendEvent(eUserLoggedIn, true);
setVisible(false);
setFocused(false);
useUserStore.setState({
disableAppLockRequests: false
});
},
true
);
const { step, passwordInputRef, loading, login, formRef } = useLogin(() => {
eSendEvent(eUserLoggedIn, true);
setVisible(false);
setFocused(false);
useUserStore.setState({
disableAppLockRequests: false
});
}, true);
const logout = async () => {
try {
@@ -104,7 +101,7 @@ export const SessionExpired = () => {
if (!complete) {
const user = await db.user.getUser();
if (!user) return;
email.current = user.email;
formRef.current.setValue("email", user.email);
setVisible(true);
setFocused(false);
return;
@@ -117,14 +114,14 @@ export const SessionExpired = () => {
} catch (e) {
const user = await db.user.getUser();
if (!user) return;
email.current = user.email;
formRef.current.setValue("email", user.email);
setFocused(false);
setVisible(true);
useUserStore.setState({
disableAppLockRequests: true
});
}
}, [email]);
}, [formRef]);
useEffect(() => {
const sub = eSubscribeEvent(eLoginSessionExpired, open);
@@ -155,6 +152,7 @@ export const SessionExpired = () => {
enableSheetKeyboardHandler={true}
visible={true}
>
<Dialog context="two_factor_verify" />
<View
style={{
width: focused ? "100%" : "99.9%",
@@ -192,17 +190,17 @@ export const SessionExpired = () => {
}}
>
{strings.sessionExpiredDesc(
getObfuscatedEmail(email.current as string)
getObfuscatedEmail(formRef.current.getValue("email") as string)
)}
</Paragraph>
</View>
{step === LoginSteps.passwordAuth ? (
<Input
<FormInput
fwdRef={passwordInputRef}
onChangeText={(value) => {
password.current = value;
}}
formRef={formRef}
name="password"
validators={[validators.required(strings.passwordRequired())]}
returnKeyLabel={strings.done()}
returnKeyType="next"
secureTextEntry
@@ -210,7 +208,7 @@ export const SessionExpired = () => {
autoCapitalize="none"
autoCorrect={false}
placeholder={strings.password()}
onSubmit={() => {
onSubmitEditing={() => {
login();
}}
/>

View File

@@ -30,7 +30,6 @@ import {
import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view";
import { db } from "../../common/database";
import { DDS } from "../../services/device-detection";
import { ToastManager } from "../../services/event-manager";
import { clearMessage, setEmailVerifyMessage } from "../../services/message";
import Navigation from "../../services/navigation";
import { useUserStore } from "../../stores/use-user-store";
@@ -39,13 +38,14 @@ import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Loading } from "../loading";
import { Button } from "../ui/button";
import Input from "../ui/input";
import FormInput, { createFormRef, validators } from "../ui/input/form-input";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { AuthHeader } from "./header";
import { SignupContext } from "./signup-context";
import { RouteParams } from "../../stores/use-navigation-store";
import SettingsService from "../../services/settings";
import AppIcon from "../ui/AppIcon";
const SignupSteps = {
signup: 0,
@@ -62,13 +62,17 @@ export const Signup = ({
}) => {
const [currentStep, setCurrentStep] = useState(SignupSteps.signup);
const { colors } = useThemeColors();
const email = useRef<string>(undefined);
const formRef = useRef(
createFormRef({
email: "",
password: "",
confirmPassword: ""
})
);
const emailInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const password = useRef<string>(undefined);
const confirmPasswordInputRef = useRef<TextInput>(null);
const confirmPassword = useRef<string>(undefined);
const [error, setError] = useState(false);
const [errorMessage, setErrorMessage] = useState<string>();
const [loading, setLoading] = useState(false);
const setUser = useUserStore((state) => state.setUser);
const setLastSynced = useUserStore((state) => state.setLastSynced);
@@ -76,29 +80,17 @@ export const Signup = ({
const isTablet = width > 600;
const route = useRoute<RouteProp<RouteParams, "Auth">>();
const validateInfo = () => {
if (!password.current || !email.current || !confirmPassword.current) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
type: "error",
context: "local"
});
return false;
}
return true;
};
const signup = async () => {
if (!validateInfo() || error) return;
setErrorMessage(undefined);
if (!formRef.current.validate()) return;
if (loading) return;
const values = formRef.current.getValues();
setLoading(true);
try {
setCurrentStep(SignupSteps.createAccount);
await db.user.signup(email.current!.toLowerCase(), password.current!);
await db.user.signup(values.email.toLowerCase(), values.password);
const user = await db.user.getUser();
setUser(user);
setLastSynced(await db.lastSynced());
@@ -115,12 +107,14 @@ export const Signup = ({
} catch (e) {
setCurrentStep(SignupSteps.signup);
setLoading(false);
ToastManager.show({
heading: strings.signupFailed(),
message: (e as Error).message,
type: "error",
context: "local"
});
if (
(e as Error).message === "Unable to create an account on this email."
) {
formRef.current.setError("email", (e as Error).message);
} else {
setErrorMessage((e as Error).message);
}
return false;
}
};
@@ -215,60 +209,55 @@ export const Signup = ({
alignSelf: "center"
}}
>
<Input
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
onChangeText={(value) => {
email.current = value;
}}
defaultValue={email.current}
loading={loading}
testID="input.email"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Next"
returnKeyType="next"
autoComplete="email"
validationType="email"
keyboardType="email-address"
autoCorrect={false}
autoCapitalize="none"
errorMessage={strings.emailInvalid()}
placeholder={strings.email()}
blurOnSubmit={false}
onSubmit={() => {
if (!email.current) return;
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterAValidEmailAddress())
]}
onSubmitEditing={() => {
passwordInputRef.current?.focus();
}}
/>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passwordInputRef}
onChangeText={(value) => {
password.current = value;
}}
defaultValue={password.current}
loading={loading}
testID="input.password"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Next"
returnKeyType="next"
secureTextEntry
autoComplete="password"
autoCapitalize="none"
blurOnSubmit={false}
validationType="password"
autoCorrect={false}
placeholder={strings.password()}
onSubmit={() => {
if (!password.current) return;
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={() => {
confirmPasswordInputRef.current?.focus();
}}
/>
<Input
<FormInput
name="confirmPassword"
formRef={formRef}
fwdRef={confirmPasswordInputRef}
onChangeText={(value) => {
confirmPassword.current = value;
}}
defaultValue={confirmPassword.current}
loading={loading}
testID="input.confirmPassword"
onErrorCheck={(e) => setError(e)}
returnKeyLabel="Signup"
returnKeyType="done"
secureTextEntry
@@ -276,17 +265,22 @@ export const Signup = ({
autoCapitalize="none"
autoCorrect={false}
blurOnSubmit={false}
validationType="confirmPassword"
customValidator={() => password.current!}
placeholder={strings.confirmPassword()}
marginBottom={12}
onSubmit={() => {
validators={[
validators.required(strings.confirmPasswordRequired()),
validators.matchField(
"password",
strings.passwordNotMatched()
)
]}
onSubmitEditing={() => {
signup();
}}
/>
<Button
title={!loading ? "Continue" : null}
title={!loading ? strings.continue() : null}
type="accent"
loading={loading}
onPress={() => {
@@ -320,6 +314,25 @@ export const Signup = ({
</Paragraph>
</Paragraph>
</TouchableOpacity>
{errorMessage ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{errorMessage}
</Paragraph>
) : null}
</View>
<View

View File

@@ -27,14 +27,15 @@ import useTimer from "../../hooks/use-timer";
import { eSendEvent, ToastManager } from "../../services/event-manager";
import { eCloseSimpleDialog } from "../../utils/events";
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 { Pressable } from "../ui/pressable";
import Heading from "../ui/typography/heading";
import Paragraph from "../ui/typography/paragraph";
import { DefaultAppStyles } from "../../utils/styles";
import { presentDialog } from "../dialog/functions";
type MFAInfo = {
primaryMethod: string;
@@ -52,7 +53,8 @@ const TwoFactorVerification = ({
method: string;
code: string;
},
callback: (result: any) => void
callback: (result: any) => void,
onerror: (e: Error) => void
) => Promise<void>;
mfaInfo: MFAInfo;
onCancel: () => void;
@@ -66,15 +68,26 @@ const TwoFactorVerification = ({
method: mfaInfo?.primaryMethod,
isPrimary: true
});
const { seconds, start, reset } = useTimer(currentMethod.method!);
const { seconds, start, reset, secondsRef } = useTimer(currentMethod.method!);
const [loading, setLoading] = useState(false);
const inputRef = useRef<TextInput>(null);
const [sending, setSending] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);
const onNext = async () => {
if (!code.current || code.current.length < 6 || !currentMethod.method)
if (!code.current || code.current.length < 6) {
setError(
new Error("Please provide a valid multi-factor authentication code.")
);
return;
}
if (!currentMethod.method) {
return;
}
setLoading(true);
setError(undefined);
inputRef.current?.blur();
await onMfaLogin(
{
@@ -86,6 +99,9 @@ const TwoFactorVerification = ({
eSendEvent(eCloseSimpleDialog, "two_factor_verify");
}
setLoading(false);
},
(e) => {
setError(e);
}
);
setLoading(false);
@@ -131,7 +147,7 @@ const TwoFactorVerification = ({
};
const onSendCode = useCallback(async () => {
if (seconds || sending) return;
if (secondsRef.current || sending) return;
setSending(true);
try {
await db.mfa.sendCode(currentMethod.method as "sms" | "email");
@@ -139,15 +155,18 @@ const TwoFactorVerification = ({
setSending(false);
} catch (e) {
setSending(false);
ToastManager.error(e as Error, "Error sending 2FA Code", "local");
setError(
new Error(`Error sending 2FA Code. Tap "Send code" to try again `)
);
}
}, [currentMethod.method, mfaInfo.token, seconds, sending, start]);
}, [currentMethod.method, secondsRef, sending, start]);
useEffect(() => {
if (currentMethod.method === "sms" || currentMethod.method === "email") {
onSendCode();
}
}, [currentMethod.method, onSendCode]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentMethod.method]);
return (
<ScrollView
@@ -234,6 +253,7 @@ const TwoFactorVerification = ({
fwdRef={inputRef}
textAlign="center"
onChangeText={(value) => {
setError(undefined);
code.current = value;
}}
cursorColor={colors.selected.accent}
@@ -241,6 +261,7 @@ const TwoFactorVerification = ({
selectionColor={colors.selected.accent}
onSubmitEditing={onNext}
height={60}
marginBottom={0}
inputStyle={{
fontSize: AppFontSize.lg,
textAlign: "center",
@@ -254,10 +275,26 @@ const TwoFactorVerification = ({
containerStyle={{
minWidth: "50%"
}}
wrapperStyle={{
height: 60
}}
/>
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center",
marginVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
maxWidth: 250
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error?.message}
</Paragraph>
) : null}
<Button
title={loading ? null : strings.next()}
@@ -338,7 +375,8 @@ TwoFactorVerification.present = (
method: string;
code: string;
},
callback: (result: any) => void
callback: (result: any) => void,
onerror: (e: Error) => void
) => Promise<void>,
data: MFAInfo,
onCancel: () => void,

View File

@@ -28,6 +28,7 @@ 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,
@@ -39,49 +40,37 @@ export const useLogin = (
onFinishLogin?: () => void,
sessionExpired = false
) => {
const [error, setError] = useState(false);
const [error, setError] = useState<Error>();
const [loading, setLoading] = useState(false);
const setUser = useUserStore((state) => state.setUser);
const [step, setStep] = useState(LoginSteps.emailAuth);
const email = useRef<string>(undefined);
const password = useRef<string>(undefined);
const emailInputRef = useRef<TextInput>(null);
const passwordInputRef = useRef<TextInput>(null);
const validateInfo = () => {
if (
(!password.current && step === LoginSteps.passwordAuth) ||
(!email.current && step === LoginSteps.emailAuth)
) {
ToastManager.show({
heading: strings.allFieldsRequired(),
message: strings.allFieldsRequiredDesc(),
type: "error",
context: "local"
});
return false;
}
return true;
};
const formRef = useRef(
createFormRef({
email: "",
password: ""
})
);
const login = async () => {
if (!validateInfo() || error) return;
try {
if (loading) return;
setError(undefined);
setLoading(true);
switch (step) {
case LoginSteps.emailAuth: {
if (!email.current) {
if (formRef.current.validateField("email")) {
setLoading(false);
return;
}
const mfaInfo = await db.user.authenticateEmail(email.current);
const mfaInfo = await db.user.authenticateEmail(
formRef.current.getValue("email")
);
if (mfaInfo) {
TwoFactorVerification.present(
async (mfa: any, callback: (success: boolean) => void) => {
async (mfa: any, callback: (success: boolean) => void, onerror: (e: Error) => void) => {
try {
const success = await db.user.authenticateMultiFactorCode(
mfa.code,
@@ -103,6 +92,9 @@ export const useLogin = (
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);
}
}
},
@@ -119,13 +111,14 @@ export const useLogin = (
break;
}
case LoginSteps.passwordAuth: {
if (!email.current || !password.current) {
if (!formRef.current.validate()) {
setLoading(false);
return;
}
const values = formRef.current.getValues();
await db.user.authenticatePassword(
email.current,
password.current,
values.email,
values.password,
undefined,
sessionExpired
);
@@ -142,12 +135,11 @@ export const useLogin = (
const finishWithError = async (e: Error) => {
if (e.message === "invalid_grant") setStep(LoginSteps.emailAuth);
setLoading(false);
ToastManager.show({
heading: strings.loginFailed(),
message: e.message,
type: "error",
context: "local"
});
if (e.message === "Password is incorrect.") {
formRef.current.setError("password", e.message);
} else {
setError(e);
}
};
const finishLogin = async () => {
@@ -174,13 +166,12 @@ export const useLogin = (
login,
step,
setStep,
email,
password,
passwordInputRef,
emailInputRef,
loading,
setLoading,
error,
setError
setError,
formRef
};
};

View File

@@ -136,7 +136,7 @@ const BaseDialog = ({
? background
: transparent
? "transparent"
: "rgba(0,0,0,0.3)"
: "rgba(0,0,0,0.1)"
}}
>
<KeyboardAvoidingView

View File

@@ -20,7 +20,7 @@ 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 { StyleSheet, View } from "react-native";
import { ActivityIndicator, StyleSheet, View } from "react-native";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { notesnook } from "../../../e2e/test.ids";
import { getColorLinearShade } from "../../utils/colors";
@@ -79,6 +79,11 @@ const DialogButtons = ({
/>
<Paragraph color={colors.primary.accent}>{" " + doneText}</Paragraph>
</View>
) : loading ? (
<ActivityIndicator
size={AppFontSize.lg}
color={colors.primary.accent}
/>
) : (
<View />
)}
@@ -105,7 +110,6 @@ const DialogButtons = ({
style={{
marginLeft: 10
}}
loading={loading}
bold
type={positiveType || "transparent"}
title={positiveTitle}

View File

@@ -17,10 +17,12 @@ 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 { KeyboardTypeOptions } from "react-native";
import { KeyboardTypeOptions, TextInput } from "react-native";
import { eSendEvent } from "../../services/event-manager";
import { eCloseSimpleDialog, eOpenSimpleDialog } from "../../utils/events";
import { ButtonProps } from "../ui/button";
import { FieldValidator, FormRef } from "../ui/input/form-input";
import { RefObject } from "react";
export type DialogInfo = {
title?: string;
@@ -43,6 +45,18 @@ export type DialogInfo = {
| "errorShade";
icon?: string;
paragraphColor: string;
form?: {
formRef: FormRef;
items: {
name: string;
placeholder: string;
label?: string;
validators: FieldValidator[];
defaultValue?: string;
ref: RefObject<TextInput | null>;
}[];
onFormSubmit?: (form: FormRef) => Promise<boolean>;
};
input: boolean;
inputPlaceholder: string;
defaultValue: string;

View File

@@ -18,7 +18,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useThemeColors } from "@notesnook/theme";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useRef,
useState,
RefObject
} from "react";
import { TextInput, View, ViewStyle } from "react-native";
import { DDS } from "../../services/device-detection";
import {
@@ -34,6 +40,7 @@ import { sleep } from "../../utils/time";
import { Toast } from "../toast";
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";
@@ -53,15 +60,38 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
});
const inputRef = useRef<TextInput>(null);
const [dialogInfo, setDialogInfo] = useState<DialogInfo>();
const formRef = useRef(dialogInfo?.form?.formRef);
formRef.current = dialogInfo?.form?.formRef;
const onPressPositive = async () => {
if (dialogInfo?.positivePress) {
// Handle form submission if form is available
if (dialogInfo?.form && formRef.current) {
inputRef.current?.blur();
try {
const isValid = await formRef.current.validate();
if (!isValid) {
return;
}
if (dialogInfo.form.onFormSubmit) {
setLoading(true);
const result = await dialogInfo.form.onFormSubmit(formRef.current);
if (result === false) {
setLoading(false);
return;
}
}
} catch (e) {
/** Empty */
}
setLoading(false);
} else if (dialogInfo?.positivePress) {
// Handle old input-based submission
inputRef.current?.blur();
setLoading(true);
let result = false;
try {
result = await dialogInfo.positivePress(
values.current.inputValue || dialogInfo.defaultValue,
values.current.inputValue,
checked
);
} catch (e) {
@@ -76,6 +106,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
setChecked(false);
values.current.inputValue = undefined;
formRef.current = undefined;
setVisible(false);
};
@@ -85,6 +116,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
if (data.context !== context) return;
setDialogInfo(data);
setChecked(data.check?.defaultValue);
formRef.current = data?.form?.formRef;
values.current.inputValue = data.defaultValue;
setVisible(true);
},
@@ -94,6 +126,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
const hide = React.useCallback(() => {
setChecked(false);
values.current.inputValue = undefined;
formRef.current = undefined;
setVisible(false);
setDialogInfo(undefined);
dialogInfo?.onClose?.();
@@ -134,19 +167,30 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
? false
: dialogInfo.statusBarTranslucent
}
bounce={!dialogInfo.input}
bounce={!dialogInfo.input && !dialogInfo.form}
closeOnTouch={!dialogInfo.disableBackdropClosing}
background={dialogInfo.background}
transparent={
dialogInfo.transparent === undefined ? true : dialogInfo.transparent
dialogInfo.transparent === undefined ? false : dialogInfo.transparent
}
onShow={async () => {
if (dialogInfo.input) {
if (dialogInfo.input && !dialogInfo.form) {
inputRef.current?.setNativeProps({
text: dialogInfo.defaultValue
});
await sleep(300);
inputRef.current?.focus();
} else if (dialogInfo.form) {
const items = dialogInfo.form?.items;
const firstItem = items[0];
for (const item of items) {
if (item.defaultValue) {
item.ref.current?.setNativeProps({
text: dialogInfo.defaultValue
});
}
}
firstItem?.ref?.current?.focus();
}
}}
visible={true}
@@ -170,7 +214,36 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
/>
<Seperator half />
{dialogInfo.input ? (
{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
@@ -184,7 +257,7 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
}}
testID="input-value"
secureTextEntry={dialogInfo.secureTextEntry}
//defaultValue={dialogInfo.defaultValue}
defaultValue={dialogInfo.defaultValue}
onSubmit={() => {
onPressPositive();
}}
@@ -237,7 +310,10 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
<DialogButtons
onPressNegative={onNegativePress}
onPressPositive={dialogInfo.positivePress && onPressPositive}
onPressPositive={
(dialogInfo.positivePress || dialogInfo.form?.onFormSubmit) &&
onPressPositive
}
loading={loading}
positiveTitle={dialogInfo.positiveText}
negativeTitle={dialogInfo.negativeText}

View File

@@ -201,12 +201,14 @@ export const AppLockPassword = () => {
accountPass
? strings.enterAccountPassword()
: mode === "change"
? keyboardType === "pin"
? strings.newPin()
: strings.newPassword()
: `${
keyboardType === "pin" ? strings.pin() : strings.password()
}`
? keyboardType === "pin"
? strings.newPin()
: strings.newPassword()
: `${
keyboardType === "pin"
? strings.pin()
: strings.password()
}`
}
/>
@@ -364,7 +366,9 @@ export const AppLockPassword = () => {
SettingsService.getProperty("biometricsAuthEnabled") === false
) {
SettingsService.setProperty("appLockEnabled", false);
SettingsService.setPrivacyScreen(SettingsService.get());
SettingsService.setPrivacyScreen(
SettingsService.getProperty("privacyScreen")
);
ToastManager.show({
message: strings.applockDisabled(),
type: "success"
@@ -383,8 +387,8 @@ export const AppLockPassword = () => {
mode === "remove"
? strings.remove()
: mode === "change"
? strings.change()
: strings.save()
? strings.change()
: strings.save()
}
negativeTitle={strings.cancel()}
positiveType="transparent"

View File

@@ -17,7 +17,13 @@ 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 { GroupHeader, GroupOptions, ItemType } from "@notesnook/core";
import {
GroupHeader,
GroupingByIdKey,
GroupingKey,
GroupOptions,
ItemType
} from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
@@ -40,7 +46,11 @@ type SectionHeaderProps = {
color?: string;
screen?: RouteName;
groupOptions: GroupOptions;
group: GroupingKey;
groupId?: string;
type?: GroupingByIdKey;
onOpenJumpToDialog: () => void;
itemCount?: number;
};
export const SectionHeader = React.memo<
@@ -53,11 +63,15 @@ export const SectionHeader = React.memo<
color,
screen,
groupOptions,
onOpenJumpToDialog
group,
onOpenJumpToDialog,
itemCount,
groupId,
type
}: SectionHeaderProps) {
const { colors } = useThemeColors();
const isCompactModeEnabled = useIsCompactModeEnabled(
dataType as "note" | "notebook"
dataType as "note" | "notebook" | "searchResult"
);
return (
@@ -104,7 +118,9 @@ export const SectionHeader = React.memo<
color={color || colors.primary.accent}
>
{!item.title || item.title === ""
? strings.pinned().toUpperCase()
? screen === "Search"
? strings.results(itemCount || 0)
: strings.pinned().toUpperCase()
: item.title.toUpperCase()}
</Heading>
</Pressable>
@@ -132,7 +148,10 @@ export const SectionHeader = React.memo<
component: (
<Sort
screen={screen}
type={dataType}
dataType={dataType}
type={type}
group={group}
groupId={groupId}
hideGroupOptions={
screen === "Reminders" || screen === "Search"
}
@@ -150,7 +169,8 @@ export const SectionHeader = React.memo<
hidden={
dataType !== "note" &&
dataType !== "notebook" &&
screen !== "Notes"
screen !== "Notes" &&
screen !== "Search"
}
style={{
width: 25,
@@ -163,9 +183,11 @@ export const SectionHeader = React.memo<
}
onPress={() => {
SettingsService.set({
[dataType !== "notebook"
? "notesListMode"
: "notebooksListMode"]: !isCompactModeEnabled
[dataType === "notebook"
? "notebooksListMode"
: dataType === "searchResult"
? "searchListMode"
: "notesListMode"]: !isCompactModeEnabled
? "compact"
: "normal"
});
@@ -191,6 +213,7 @@ export const SectionHeader = React.memo<
},
(prev, next) => {
if (prev.item.title !== next.item.title) return false;
if (prev.itemCount !== next.itemCount) return false;
if (prev.groupOptions?.groupBy !== next.groupOptions.groupBy) return false;
if (prev.groupOptions?.sortDirection !== next.groupOptions.sortDirection)
return false;

View File

@@ -31,6 +31,7 @@ import { eSendEvent } from "../../../services/event-manager";
import { eOnLoadNote } from "../../../utils/events";
import { IconButton } from "../../ui/icon-button";
import { fluidTabsRef } from "../../../utils/global-refs";
import { useSettingStore } from "../../../stores/use-setting-store";
type SearchResultProps = {
item: HighlightedResult;
};
@@ -38,6 +39,9 @@ type SearchResultProps = {
export const SearchResult = (props: SearchResultProps) => {
const [expanded, setExpanded] = React.useState(true);
const { colors } = useThemeColors();
const compactMode = useSettingStore(
(state) => state.settings.searchListMode === "compact"
);
const openNote = async (index?: number) => {
const note = await db.notes.note(props.item.id);
@@ -87,7 +91,7 @@ export const SearchResult = (props: SearchResultProps) => {
flexShrink: 1
}}
>
{props.item.content?.length ? (
{props.item.content?.length && !compactMode ? (
<IconButton
name={!expanded ? "chevron-right" : "chevron-down"}
onPress={() => setExpanded((prev) => !prev)}
@@ -130,6 +134,7 @@ export const SearchResult = (props: SearchResultProps) => {
</View>
{expanded &&
!compactMode &&
props.item.content.map((content, index) => (
<Pressable
key={props.item.id + index}

View File

@@ -17,7 +17,12 @@ 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 { GroupingKey, Item, VirtualizedGrouping } from "@notesnook/core";
import {
GroupingByIdKey,
GroupingKey,
Item,
VirtualizedGrouping
} from "@notesnook/core";
import { useThemeColors } from "@notesnook/theme";
import { LegendList, LegendListRenderItemProps } from "@legendapp/list";
import React, { useEffect, useRef } from "react";
@@ -53,7 +58,9 @@ type ListProps = {
isRenderedInActionSheet?: boolean;
CustomListComponent?: React.JSX.ElementType;
placeholder?: PlaceholderData;
groupType: GroupingKey;
id?: string;
type?: GroupingByIdKey;
};
const onMomentumScrollEnd = () => {
@@ -73,16 +80,7 @@ export default function List(props: ListProps) {
props.dataType === "notebook" ||
notebooksListMode === "compact";
const groupType =
props.renderedInRoute === "Notes"
? "home"
: props.renderedInRoute === "Favorites"
? "favorites"
: props.renderedInRoute === "Trash" || props.dataType === "trash"
? "trash"
: `${props.dataType}s`;
const groupOptions = useGroupOptions(groupType);
const groupOptions = useGroupOptions(props.groupType, props.id, props.type);
const _onRefresh = async () => {
Sync.run("global", false, "full", () => {
@@ -104,23 +102,27 @@ export default function List(props: ListProps) {
index={itemProps.index}
isSheet={props.isRenderedInActionSheet || false}
items={props.data}
groupId={props.id}
groupOptions={groupOptions}
group={groupType as GroupingKey}
group={props.groupType as GroupingKey}
renderedInRoute={props.renderedInRoute}
customAccentColor={props.customAccentColor}
dataType={props.dataType}
type={props.type}
scrollRef={scrollRef}
/>
);
},
[
groupOptions,
groupType,
props.customAccentColor,
props.data,
props.dataType,
props.isRenderedInActionSheet,
props.renderedInRoute
props.data,
props.id,
props.groupType,
props.renderedInRoute,
props.customAccentColor,
props.dataType,
groupOptions,
props.type
]
);

View File

@@ -26,6 +26,7 @@ import {
Color,
GroupHeader,
GroupOptions,
GroupingByIdKey,
GroupingKey,
HighlightedResult,
Item,
@@ -40,7 +41,7 @@ import {
} from "@notesnook/core";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { View } from "react-native";
import { db } from "../../common/database";
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";
@@ -49,11 +50,11 @@ import { SectionHeader } from "../list-items/headers/section-header";
import { NoteWrapper } from "../list-items/note/wrapper";
import { NotebookWrapper } from "../list-items/notebook/wrapper";
import ReminderItem from "../list-items/reminder";
import TagItem from "../list-items/tag";
import { SearchResult } from "../list-items/search-result";
import TagItem from "../list-items/tag";
type ListItemWrapperProps<TItem = Item> = {
group?: GroupingKey;
group: GroupingKey;
items: VirtualizedGrouping<TItem> | undefined;
isSheet: boolean;
index: number;
@@ -62,6 +63,8 @@ type ListItemWrapperProps<TItem = Item> = {
dataType: string;
scrollRef: any;
groupOptions: GroupOptions;
groupId?: string;
type?: GroupingByIdKey;
};
export function ListItemWrapper(props: ListItemWrapperProps) {
@@ -182,6 +185,9 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
item={groupHeader}
index={index}
dataType={item.type}
group={group}
groupId={props.groupId}
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
@@ -218,6 +224,9 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
item={groupHeader}
index={index}
dataType={item.type}
group={group}
groupId={props.groupId}
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
@@ -245,8 +254,11 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
screen={props.renderedInRoute}
item={groupHeader}
index={index}
group={group}
dataType={item.type}
color={props.customAccentColor}
type={props.type}
groupId={props.groupId}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
eSendEvent(eOpenJumpToDialog, {
@@ -271,7 +283,10 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
screen={props.renderedInRoute}
item={groupHeader}
index={index}
group={group}
dataType={item.type}
groupId={props.groupId}
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
@@ -297,9 +312,13 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
screen={props.renderedInRoute}
item={groupHeader}
index={index}
group={group}
dataType={item.type}
groupId={props.groupId}
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
itemCount={items?.placeholders.length}
onOpenJumpToDialog={() => {
eSendEvent(eOpenJumpToDialog, {
ref: props.scrollRef,
@@ -316,11 +335,16 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
}
}
function getDate(item: Notebook | Note, groupType?: GroupingKey): number {
function getDate(
item: Notebook | Note,
groupType?: GroupingKey,
id?: string,
type?: GroupingByIdKey
): number {
return (
getSortValue(
groupType
? db.settings.getGroupOptions(groupType)
? getGroupOptions(groupType, id, type)
: {
sortBy: "dateEdited",
sortDirection: "desc"

View File

@@ -76,7 +76,9 @@ const MergeConflicts = () => {
const applyChanges = async () => {
const contentToSave = selectedContent;
if (!contentToSave) return;
const note = await db.notes.note(contentToSave.noteId);
const note = await db.notes.note(
(content.current as UnencryptedContentItem).noteId
);
if (!note) return;
await db.notes.add({
id: note.id,

View File

@@ -17,18 +17,18 @@ 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 { Debug } from "@notesnook/core";
import { Debug, IssueReportResponse } from "@notesnook/core";
import { getModel, getBrand, getSystemVersion } from "react-native-device-info";
import { useThemeColors } from "@notesnook/theme";
import React, { useRef, useState } from "react";
import { Linking, Platform, Text, TextInput, View } from "react-native";
import { getVersion } from "react-native-device-info";
import { useStoredRef } from "../../../hooks/use-stored-ref";
import { ToastManager } from "../../../services/event-manager";
import { eSendEvent, ToastManager } from "../../../services/event-manager";
import PremiumService from "../../../services/premium";
import { useUserStore } from "../../../stores/use-user-store";
import { openLinkInBrowser } from "../../../utils/functions";
import { defaultBorderRadius, AppFontSize } from "../../../utils/size";
import { defaultBorderRadius, AppFontSize } from "../../../utils/size/index";
import DialogHeader from "../../dialog/dialog-header";
import { Button } from "../../ui/button";
import Seperator from "../../ui/seperator";
@@ -37,17 +37,26 @@ import Paragraph from "../../ui/typography/paragraph";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../../utils/styles";
import Config from "react-native-config";
import { eCloseSheet } from "../../../utils/events";
export const Issue = ({ defaultTitle, defaultBody, issueTitle }) => {
export const Issue = ({
defaultTitle,
defaultBody,
issueTitle
}: {
defaultTitle?: string;
defaultBody?: string;
issueTitle?: string;
}) => {
const { colors } = useThemeColors();
const body = useStoredRef("issueBody");
const body = useStoredRef("issueBody", "");
const title = useStoredRef("issueTitle", defaultTitle);
const [done, setDone] = useState(false);
const user = useUserStore((state) => state.user);
const [loading, setLoading] = useState(false);
const bodyRef = useRef();
const bodyRef = useRef<TextInput>(null);
const initialLayout = useRef(false);
const issueUrl = useRef();
const issueReportResponse = useRef<IssueReportResponse>(undefined);
const onPress = async () => {
if (loading) return;
@@ -57,7 +66,7 @@ export const Issue = ({ defaultTitle, defaultBody, issueTitle }) => {
try {
setLoading(true);
issueUrl.current = await Debug.report({
issueReportResponse.current = await Debug.report({
title: title.current,
body:
body.current +
@@ -72,7 +81,7 @@ Logged in: ${user ? "yes" : "no"}
Github Release: ${Config.GITHUB_RELEASE === "true" ? "Yes" : "No"}`,
userId: user?.id
});
if (!issueUrl.current) {
if (!issueReportResponse.current) {
setLoading(false);
ToastManager.show({
heading: "Failed to report issue on github",
@@ -88,12 +97,46 @@ Github Release: ${Config.GITHUB_RELEASE === "true" ? "Yes" : "No"}`,
} catch (e) {
setLoading(false);
ToastManager.show({
heading: e.message,
heading: (e as Error).message,
type: "error"
});
}
};
function getResponseInfo(response?: IssueReportResponse) {
if (!response || "error" in response) return;
switch (response.type) {
case "email": {
return {
title: strings.yourSupportRequestHasBeenForwarded(),
message: strings.supportEmailMessage()
};
}
case "discussion": {
const url = response.url;
return {
title: strings.thankYouForFeedback(),
positiveButtonText: strings.copyLink(),
message: strings.featureRequestMessage(url),
url: url
};
}
case "issue": {
const url = response.url;
return {
title: strings.thankYouForReporting(),
positiveButtonText: strings.copyLink(),
message: strings.bugReportMessage(url),
url: url
};
}
}
}
const responseInfo = getResponseInfo(issueReportResponse.current);
console.log(responseInfo, issueReportResponse.current);
return (
<View
style={{
@@ -105,38 +148,28 @@ Github Release: ${Config.GITHUB_RELEASE === "true" ? "Yes" : "No"}`,
<>
<View
style={{
height: 250,
justifyContent: "center",
alignItems: "center",
gap: 10
}}
>
<Heading>{strings.issueCreatedHeading()}</Heading>
<Heading>{responseInfo?.title}</Heading>
<Paragraph
style={{
textAlign: "center"
}}
selectable={true}
>
{strings.issueCreatedDesc[0]()}
<Paragraph
style={{
textDecorationLine: "underline",
color: colors.primary.accent
}}
onPress={() => {
Linking.openURL(issueUrl.current);
}}
>
{issueUrl.current}
</Paragraph>
. {strings.issueCreatedDesc[1]()}
{responseInfo?.message}
</Paragraph>
<Button
title={strings.openIssue()}
title={responseInfo?.positiveButtonText || "Done"}
onPress={() => {
Linking.openURL(issueUrl.current);
if (responseInfo?.url) {
Linking.openURL(responseInfo?.url);
}
eSendEvent(eCloseSheet);
}}
type="accent"
width="100%"
@@ -248,10 +281,7 @@ Github Release: ${Config.GITHUB_RELEASE === "true" ? "Yes" : "No"}`,
}}
onPress={async () => {
try {
await openLinkInBrowser(
"https://discord.gg/zQBK97EE22",
colors
);
await openLinkInBrowser("https://discord.gg/zQBK97EE22");
} catch (e) {
console.error(e);
}

View File

@@ -45,12 +45,15 @@ import { DefaultAppStyles } from "../../../utils/styles";
import DialogHeader from "../../dialog/dialog-header";
import { Button } from "../../ui/button";
import { IconButton } from "../../ui/icon-button";
import Input from "../../ui/input";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
import { useAsync } from "react-async-hook";
import { eMenuItemUpdate } from "../../../utils/events";
import { useIsFeatureAvailable } from "@notesnook/common";
import FormInput, {
createFormRef,
validators
} from "../../ui/input/form-input";
async function fetchMonographData(noteId: string) {
const monographId = db.monographs.monograph(noteId);
@@ -76,26 +79,31 @@ const PublishNoteSheet = ({
const isFeatureAvailable = useIsFeatureAvailable("monographAnalytics");
const [isLocked, setIsLocked] = useState(false);
const [publishing, setPublishing] = useState(false);
const customTitle = useRef<string>("");
const pwdInput = useRef<TextInput>(null);
const titleInput = useRef<TextInput>(null);
const passwordValue = useRef<string>(undefined);
const monographData = useAsync(async () => {
return fetchMonographData(note?.id);
}, []);
const monograph = monographData.result?.monograph;
customTitle.current = monograph?.title || note.title || "";
const publishUrl = monograph && `${hosts.MONOGRAPH_HOST}/${monograph?.id}`;
const isPublished = db.monographs.monograph(note?.id);
const formRef = useRef(
createFormRef({
title: monograph?.title || note.title || "",
password: ""
})
);
useEffect(() => {
(async () => {
if (monograph) {
setSelfDestruct(!!monograph?.selfDestruct);
if (monograph.password) {
passwordValue.current = await db.monographs.decryptPassword(
const password = await db.monographs.decryptPassword(
monograph?.password
);
formRef.current.setValue("password", password);
setIsLocked(!!monograph?.password);
}
}
@@ -104,20 +112,23 @@ const PublishNoteSheet = ({
const publishNote = async () => {
if (publishing) return;
formRef.current.clearErrors();
if (!formRef.current.validate()) return;
const values = formRef.current.getValues();
setPublishLoading(true);
try {
if (note?.id) {
if (isLocked && !passwordValue.current) return;
await db.monographs.publish(note.id, customTitle.current, {
selfDestruct: selfDestruct,
password: isLocked ? passwordValue.current : undefined
await db.monographs.publish(note.id, values.title, {
selfDestruct,
password: isLocked ? values.password : undefined
});
await monographData.execute();
Navigation.queueRoutesForUpdate();
eSendEvent(eMenuItemUpdate);
setPublishLoading(false);
}
requestInAppReview();
} catch (e) {
@@ -127,9 +138,9 @@ const PublishNoteSheet = ({
type: "error",
context: "local"
});
} finally {
setPublishLoading(false);
}
setPublishLoading(false);
};
const setPublishLoading = (value: boolean) => {
setPublishing(value);
@@ -247,11 +258,12 @@ const PublishNoteSheet = ({
</TouchableOpacity>
) : null}
<Input
<FormInput
name="title"
formRef={formRef}
fwdRef={titleInput}
onChangeText={(value) => (customTitle.current = value)}
defaultValue={customTitle.current}
placeholder={strings.noteTitle()}
validators={[validators.required(strings.titleIsRequired())]}
/>
<TouchableOpacity
@@ -298,13 +310,16 @@ const PublishNoteSheet = ({
{isLocked ? (
<>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={pwdInput}
onChangeText={(value) => (passwordValue.current = value)}
blurOnSubmit
secureTextEntry
defaultValue={passwordValue.current}
placeholder={strings.enterPassword()}
validators={[
validators.required(strings.passwordRequired())
]}
containerStyle={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}

View File

@@ -1,162 +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 { Item, ItemReference, VirtualizedGrouping } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { RefObject, useEffect, useState } from "react";
import { View } from "react-native";
import { ActionSheetRef } from "react-native-actions-sheet";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { db } from "../../../common/database";
import {
PresentSheetOptions,
presentSheet
} from "../../../services/event-manager";
import { useRelationStore } from "../../../stores/use-relation-store";
import { AppFontSize } from "../../../utils/size";
import { DefaultAppStyles } from "../../../utils/styles";
import DialogHeader from "../../dialog/dialog-header";
import List from "../../list";
import SheetProvider from "../../sheet-provider";
import { Button, ButtonProps } from "../../ui/button";
import Paragraph from "../../ui/typography/paragraph";
type RelationsListProps = {
actionSheetRef: RefObject<ActionSheetRef>;
close?: () => void;
update?: (options: PresentSheetOptions) => void;
item: { id: string; type: string };
referenceType: string;
relationType: "to" | "from";
title: string;
button?: ButtonProps;
onAdd: () => void;
};
const IconsByType = {
reminder: "bell"
};
export const RelationsList = ({
actionSheetRef,
item,
referenceType,
relationType,
title,
button,
onAdd
}: RelationsListProps) => {
const updater = useRelationStore((state) => state.updater);
const { colors } = useThemeColors();
const [items, setItems] = useState<VirtualizedGrouping<Item>>();
const hasNoRelations = !items || items?.placeholders?.length === 0;
useEffect(() => {
db.relations?.[relationType]?.(
{ id: item?.id, type: item?.type } as ItemReference,
referenceType as any
)
.selector.sorted({
sortBy: "dateEdited",
sortDirection: "desc"
})
.then((grouped) => {
setTimeout(() => {
setItems(grouped);
}, 300);
});
}, [relationType, referenceType, item?.id, item?.type, updater]);
return (
<View style={{ paddingHorizontal: DefaultAppStyles.GAP, height: "100%" }}>
<SheetProvider context="local" />
<DialogHeader
title={title}
button={hasNoRelations ? undefined : button}
/>
{hasNoRelations ? (
<View
style={{
height: "85%",
justifyContent: "center",
alignItems: "center"
}}
>
<Icon
name={IconsByType[referenceType as keyof typeof IconsByType]}
size={60}
color={colors.primary.icon}
/>
<Paragraph>{strings.noLinksFound()}</Paragraph>
<Button
onPress={() => {
onAdd?.();
}}
fontSize={AppFontSize.sm}
// width="100%"
type="inverted"
icon="plus"
title={strings.addItem(
referenceType as "notebook" | "tag" | "reminder" | "note"
)}
/>
</View>
) : (
<List
data={items}
loading={false}
dataType={referenceType as any}
isRenderedInActionSheet={true}
/>
)}
</View>
);
};
RelationsList.present = ({
reference,
referenceType,
relationType,
title,
button,
onAdd
}: {
reference: { id: string; type: string };
referenceType: string;
relationType: "to" | "from";
title: string;
button?: ButtonProps;
onAdd: () => void;
}) => {
presentSheet({
component: (ref, close, update) => (
<RelationsList
actionSheetRef={ref}
close={close}
update={update}
item={reference}
referenceType={referenceType}
relationType={relationType}
title={title}
button={button}
onAdd={onAdd}
/>
)
});
};

View File

@@ -181,6 +181,7 @@ export default function ReminderNotify({
data={references}
loading={false}
dataType="note"
groupType="notes"
isRenderedInActionSheet={true}
/>
</View>

View File

@@ -18,6 +18,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import {
GroupingByIdKey,
GroupingKey,
GroupOptions,
ItemType,
@@ -27,7 +28,10 @@ import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useState } from "react";
import { View } from "react-native";
import { db } from "../../../common/database";
import {
getGroupOptions,
setGroupOptionsById
} from "../../../hooks/use-group-options";
import { eSendEvent } from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
import { RouteName } from "../../../stores/use-navigation-store";
@@ -43,29 +47,24 @@ import { Pressable } from "../../ui/pressable";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
const Sort = ({
type,
dataType,
screen,
hideGroupOptions
hideGroupOptions,
group: groupType,
groupId,
type
}: {
type: ItemType;
dataType: ItemType;
type?: GroupingByIdKey;
screen?: RouteName;
group: GroupingKey;
hideGroupOptions?: boolean;
groupId?: string;
}) => {
const { colors } = useThemeColors();
const groupType =
screen === "Archive"
? "archive"
: screen === "Search"
? "search"
: screen === "Notes"
? "home"
: screen === "Trash" || type === "trash"
? "trash"
: ((type + "s") as GroupingKey);
const [groupOptions, setGroupOptions] = useState(
db.settings.getGroupOptions(groupType)
getGroupOptions(groupType, groupId, type)
);
const getSortButtonTitle = () => {
@@ -88,16 +87,17 @@ const Sort = ({
};
const updateGroupOptions = async (_groupOptions: GroupOptions) => {
await db.settings.setGroupOptions(groupType, _groupOptions);
console.log(groupId, type);
setGroupOptionsById(groupType, _groupOptions, groupId, type);
setGroupOptions(_groupOptions);
setTimeout(() => {
if (screen) Navigation.queueRoutesForUpdate(screen);
if (type === "notebook") {
if (dataType === "notebook") {
useNotebookStore.getState().refresh();
} else if (type === "tag") {
} else if (dataType === "tag") {
useTagStore.getState().refresh();
}
eSendEvent(eGroupOptionsUpdated, groupType);
eSendEvent(eGroupOptionsUpdated, groupType, groupId, type);
eSendEvent(refreshNotesPage);
}, 1);
};
@@ -157,10 +157,15 @@ const Sort = ({
>
{Object.keys(SORT).map((item) => {
const sortOptionVisibility = {
relevance: screen === "Search",
dueDate: screen === "Reminders",
dateModified: screen === "Tags" || screen === "Reminders",
dateEdited: screen !== "Tags" && screen !== "Reminders"
dateCreated: groupType !== "trash",
relevance: groupType === "search",
dueDate: groupType === "reminders",
dateModified: groupType === "reminders" || groupType === "tags",
dateEdited:
groupType !== "tags" &&
groupType !== "reminders" &&
groupType !== "trash",
dateDeleted: groupType === "trash"
};
// Check if this sort option should be skipped for the current screen
@@ -188,10 +193,7 @@ const Sort = ({
onPress={async () => {
const _groupOptions: GroupOptions = {
...groupOptions,
sortBy:
type === "trash"
? "dateDeleted"
: (item as SortOptions["sortBy"])
sortBy: item as SortOptions["sortBy"]
};
await updateGroupOptions(_groupOptions);
}}

View File

@@ -433,7 +433,7 @@ const TabBar = (props: SimpleTabBarProps) => {
name="plus"
testID="sidebar-add-button"
size={AppFontSize.lg - 2}
top={10}
top={10}
color={colors.primary.icon}
onPress={async () => {
if (props.navigationState.index === 1) {
@@ -493,11 +493,16 @@ const TabBar = (props: SimpleTabBarProps) => {
presentSheet({
component: (
<Sort
type={
dataType={
props.navigationState.index === 1
? "notebook"
: "tag"
}
group={
props.navigationState.index === 1
? "notebooks"
: "tags"
}
hideGroupOptions
/>
)

View File

@@ -0,0 +1,411 @@
/*
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, { RefObject, useEffect, useMemo, useState } from "react";
import {
ColorValue,
TextInput,
TextInputProps,
TextStyle,
TouchableOpacity,
View,
ViewStyle
} from "react-native";
import isEmail from "validator/lib/isEmail";
import isURL from "validator/lib/isURL";
import { useThemeColors } from "@notesnook/theme";
import { defaultBorderRadius, AppFontSize } from "../../../utils/size";
import { DefaultAppStyles } from "../../../utils/styles";
import { IconButton } from "../icon-button";
import Paragraph from "../typography/paragraph";
import AppIcon from "../AppIcon";
export type FormValues = Record<string, string>;
export type FormErrors = Partial<Record<string, string>>;
export type FieldValidator = (
value: string,
values: FormValues
) => string | undefined;
export type ValidationSchema = Partial<Record<string, FieldValidator[]>>;
export interface FormRef {
values: FormValues;
errors: FormErrors;
setValue: (name: string, value: string) => void;
getValue: (name: string) => string;
getValues: () => FormValues;
setError: (name: string, error?: string) => void;
getError: (name: string) => string | undefined;
clearErrors: () => void;
registerField: (name: string, validators?: FieldValidator[]) => void;
unregisterField: (name: string) => void;
validateField: (name: string) => string | undefined;
validate: () => boolean;
subscribe: (listener: () => void) => () => void;
}
export function createFormRef(initialValues: FormValues = {}): FormRef {
const listeners = new Set<() => void>();
const values: FormValues = { ...initialValues };
const errors: FormErrors = {};
const schema: ValidationSchema = {};
const notify = () => {
listeners.forEach((listener) => listener());
};
return {
values,
errors,
setValue(name, value) {
values[name] = value;
if (errors[name]) {
delete errors[name];
notify();
}
},
getValue(name) {
return values[name] ?? "";
},
getValues() {
return { ...values };
},
setError(name, error) {
if (!error) {
delete errors[name];
} else {
errors[name] = error;
}
notify();
},
getError(name) {
return errors[name];
},
clearErrors() {
Object.keys(errors).forEach((key) => delete errors[key]);
notify();
},
registerField(name, validators = []) {
schema[name] = validators;
if (values[name] === undefined) values[name] = "";
},
unregisterField(name) {
delete schema[name];
delete errors[name];
notify();
},
validateField(name) {
const fieldValidators = schema[name] || [];
const value = values[name] ?? "";
for (const validator of fieldValidators) {
const error = validator(value, values);
if (error) {
errors[name] = error;
notify();
return error;
}
}
delete errors[name];
notify();
return undefined;
},
validate() {
const nextErrors = validateForm(values, schema);
Object.keys(errors).forEach((key) => delete errors[key]);
Object.keys(nextErrors).forEach((key) => {
errors[key] = nextErrors[key];
});
notify();
return !hasFormErrors(nextErrors);
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
};
}
export function validateForm(
values: FormValues,
schema: ValidationSchema
): FormErrors {
const errors: FormErrors = {};
Object.keys(schema).forEach((field) => {
const fieldValidators = schema[field] || [];
const value = values[field] ?? "";
for (const validator of fieldValidators) {
const error = validator(value, values);
if (error) {
errors[field] = error;
break;
}
}
});
return errors;
}
export function hasFormErrors(errors: FormErrors) {
return Object.keys(errors).length > 0;
}
export const validators = {
required:
(message = "This field is required") =>
(value: string) =>
value?.trim() ? undefined : message,
email:
(message = "Please enter a valid email") =>
(value: string) =>
!value?.trim() || isEmail(value.trim()) ? undefined : message,
minLength: (length: number, message?: string) => (value: string) =>
!value || value.length >= length
? undefined
: message || `Must be at least ${length} characters`,
url:
(message = "Please enter a valid URL") =>
(value: string) =>
!value?.trim() || isURL(value.trim(), { allow_underscores: true })
? undefined
: message,
matchField:
(fieldName: string, message = "Values do not match") =>
(value: string, values: FormValues) =>
value === values[fieldName] ? undefined : message
};
interface FormInputProps extends TextInputProps {
name: string;
formRef: RefObject<FormRef>;
validators?: FieldValidator[];
fwdRef?: RefObject<TextInput | null>;
loading?: boolean;
error?: string;
customColor?: ColorValue;
marginBottom?: number;
marginRight?: number;
button?: {
icon: string;
color: ColorValue;
onPress: () => void;
testID?: string;
size?: number;
};
buttons?: React.ReactNode;
buttonLeft?: React.ReactNode;
height?: number;
fontSize?: number;
inputStyle?: TextInputProps["style"];
containerStyle?: ViewStyle;
wrapperStyle?: ViewStyle;
errorStyle?: TextStyle;
}
export function FormInput({
name,
formRef,
validators: fieldValidators = [],
fwdRef,
loading,
error,
secureTextEntry,
customColor,
marginBottom = 10,
marginRight,
button,
buttonLeft,
buttons,
height = 45,
fontSize = AppFontSize.sm,
inputStyle = {},
containerStyle = {},
wrapperStyle = {},
onFocus,
onBlur,
onPress,
onChangeText,
errorStyle,
...restProps
}: FormInputProps) {
const { colors, isDark } = useThemeColors();
const [focused, setFocused] = useState(false);
const [secureEntry, setSecureEntry] = useState(true);
const [, setVersion] = useState(0);
useEffect(() => {
const form = formRef.current;
form.registerField(name, fieldValidators);
const unsubscribe = form.subscribe(() => {
setVersion((v) => v + 1);
});
return () => {
unsubscribe();
form.unregisterField(name);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formRef, name]);
const value = formRef.current.getValue(name);
const fieldError = error || formRef.current.getError(name);
const borderColor = useMemo(() => {
if (fieldError) return colors.error.accent;
if (focused) return customColor || colors.selected.border;
return colors.primary.border;
}, [colors, customColor, fieldError, focused]);
const style: ViewStyle = {
borderWidth: 1,
borderRadius: defaultBorderRadius,
borderColor,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP,
...containerStyle
};
const textStyle: TextInputProps["style"] = {
paddingHorizontal: 0,
fontSize,
color:
onPress && loading ? colors.primary.accent : colors.primary.paragraph,
flexGrow: 1,
flexShrink: 1,
paddingBottom: DefaultAppStyles.GAP_VERTICAL,
paddingTop: DefaultAppStyles.GAP_VERTICAL,
fontFamily: "Inter-Regular",
...(inputStyle as ViewStyle)
};
const handleChangeText = (nextValue: string) => {
formRef.current.setValue(name, nextValue);
onChangeText?.(nextValue);
};
return (
<View
importantForAccessibility="yes"
style={{
marginBottom,
marginRight,
...wrapperStyle
}}
>
<TouchableOpacity
disabled={!loading}
onPress={onPress}
activeOpacity={1}
style={style}
>
{buttonLeft}
<TextInput
{...restProps}
defaultValue={value}
ref={fwdRef}
editable={!loading && restProps.editable !== false}
onChangeText={handleChangeText}
onFocus={(e) => {
setFocused(true);
onFocus?.(e);
}}
onBlur={(e) => {
setFocused(false);
onBlur?.(e);
}}
keyboardAppearance={isDark ? "dark" : "light"}
style={textStyle}
secureTextEntry={secureTextEntry && secureEntry}
placeholderTextColor={colors.primary.placeholder}
/>
<View
style={{
flexDirection: "row",
justifyContent: "center",
height: 35 > height ? height : 35,
alignItems: "center"
}}
>
{secureTextEntry && (
<IconButton
name="eye"
size={20}
top={10}
bottom={10}
onPress={() => {
fwdRef?.current?.blur();
setSecureEntry(!secureEntry);
}}
style={{
width: 25,
marginLeft: 5
}}
color={
secureEntry ? colors.secondary.icon : colors.primary.accent
}
/>
)}
{buttons}
{button && (
<IconButton
testID={button.testID}
name={button.icon}
size={button.size || AppFontSize.xl}
top={10}
bottom={10}
onPress={button.onPress}
color={button.color}
style={{
marginRight: -8
}}
/>
)}
</View>
</TouchableOpacity>
{fieldError ? (
<Paragraph
size={AppFontSize.xs}
style={[{ marginTop: 5, color: colors.error.icon }, errorStyle]}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{fieldError}
</Paragraph>
) : null}
</View>
);
}
export default FormInput;

View File

@@ -121,8 +121,8 @@ const Input = ({
const color = error
? colors.error.border
: focus
? customColor || colors.selected.border
: colors.primary.border;
? customColor || colors.selected.border
: colors.primary.border;
const validate = async (value: string) => {
if (!validationType) return;

View File

@@ -45,11 +45,11 @@ import ExportNotesSheet from "../components/sheets/export-notes";
import PaywallSheet from "../components/sheets/paywall";
import PublishNoteSheet from "../components/sheets/publish-note";
import { ReferencesList } from "../components/sheets/references";
import { RelationsList } from "../components/sheets/relations-list/index";
import { useSideBarDraggingStore } from "../components/side-menu/dragging-store";
import { ButtonProps } from "../components/ui/button";
import AddReminder from "../screens/add-reminder";
import { useTabStore } from "../screens/editor/tiptap/use-tab-store";
import RelationsList from "../screens/relations-list";
import {
eSendEvent,
eSubscribeEvent,
@@ -368,7 +368,14 @@ export const useActions = ({
inputPlaceholder: strings.name(),
defaultValue: item.title,
positivePress: async (value) => {
if (!value || value.trim().length === 0) return;
if (!value || value.trim().length === 0) {
ToastManager.error(
new Error(strings.nameIsRequired()),
undefined,
"local"
);
return;
}
await db.colors.add({
id: item.id,
title: value
@@ -777,6 +784,12 @@ export const useActions = ({
const duplicateNote = async () => {
await db.notes.duplicate(item.id);
Navigation.queueRoutesForUpdate();
ToastManager.show({
heading: strings.noteDuplicated(),
type: "success",
context: "local"
});
await sleep(500);
close();
};
@@ -1040,8 +1053,9 @@ export const useActions = ({
title: strings.dataTypesPluralCamelCase.reminder(),
icon: "clock-outline",
onPress: async () => {
close();
RelationsList.present({
reference: item,
item,
referenceType: "reminder",
relationType: "from",
title: strings.dataTypesPluralCamelCase.reminder(),
@@ -1058,26 +1072,6 @@ export const useActions = ({
}
AddReminder.present(undefined, item);
close();
},
button: {
type: "plain",
onPress: async () => {
if (features && !features.activeReminders.isAllowed) {
ToastManager.show({
type: "info",
message: features.activeReminders.error,
actionText: strings.upgrade(),
func: () => {
PaywallSheet.present(features.activeReminders);
}
});
return;
}
AddReminder.present(undefined, item);
close();
},
icon: "plus",
iconSize: 20
}
});
},
@@ -1121,8 +1115,9 @@ export const useActions = ({
id: "add-reminder",
title: strings.remindMe(),
icon: "clock-plus-outline",
onPress: () => {
onPress: async () => {
close();
await sleep(100);
AddReminder.present(undefined, item);
}
},

View File

@@ -95,6 +95,7 @@ import { BETA } from "../utils/constants";
import {
eAfterSync,
eCloseSheet,
eCloseSimpleDialog,
eEditorReset,
eLoginSessionExpired,
eOnLoadNote,
@@ -286,7 +287,10 @@ const onUserSubscriptionStatusChanged = async (
subscription: subscription
}
});
Walkthrough.present("prouser", false, true);
eSendEvent(eCloseSimpleDialog);
setTimeout(() => {
Walkthrough.present("prouser", false, true);
}, 500);
}
await PremiumService.setPremiumStatus();
useMessageStore.getState().setAnnouncement();

View File

@@ -16,28 +16,61 @@ 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 { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { db } from "../common/database";
import { eSubscribeEvent, eUnSubscribeEvent } from "../services/event-manager";
import Navigation from "../services/navigation";
import { eGroupOptionsUpdated } from "../utils/events";
import { useSettingStore } from "../stores/use-setting-store";
import { GroupingByIdKey, GroupingKey, GroupOptions } from "@notesnook/core";
export function useGroupOptions(type: any) {
export function getGroupOptions(
groupingKey: GroupingKey,
id?: string,
type?: GroupingByIdKey
) {
return id && type
? db.settings?.getGroupOptionsById(id, type)
: db.settings?.getGroupOptions(groupingKey);
}
export function setGroupOptionsById(
groupingKey: GroupingKey,
groupOptions: GroupOptions,
id?: string,
type?: GroupingByIdKey
) {
return id && type
? db.settings?.setGroupOptionsById(id, type, groupOptions)
: db.settings?.setGroupOptions(groupingKey, groupOptions);
}
export function useGroupOptions(
groupingKey: GroupingKey,
id?: string,
type?: GroupingByIdKey
) {
const appLoading = useSettingStore((state) => state.isAppLoading);
const [groupOptions, setGroupOptions] = useState(
db.settings?.getGroupOptions(type)
getGroupOptions(groupingKey, id, type)
);
console.log(groupingKey, id, type, groupOptions, "options");
const groupOptionsRef = useRef(groupOptions);
groupOptionsRef.current = groupOptions;
useEffect(() => {
const onUpdate = (groupType: string) => {
if (groupType !== type) return;
const options = db.settings?.getGroupOptions(type) as any;
const onUpdate = (_groupingKey: string, _id?: string, _type?: string) => {
if (_groupingKey !== groupingKey) return;
if (_id && _type && _id !== id && _type !== type) return;
const options = getGroupOptions(groupingKey, id, type);
if (!options) return;
if (
groupOptions?.groupBy !== options.groupBy ||
groupOptions?.sortBy !== options.sortBy ||
groupOptions?.sortDirection !== groupOptions?.sortDirection
groupOptionsRef.current?.groupBy !== options.groupBy ||
groupOptionsRef.current?.sortBy !== options.sortBy ||
groupOptionsRef.current?.sortDirection !== options?.sortDirection
) {
console.log("onUpdate", _id, _type);
setGroupOptions({ ...options });
Navigation.queueRoutesForUpdate();
}
@@ -46,13 +79,13 @@ export function useGroupOptions(type: any) {
eSubscribeEvent(eGroupOptionsUpdated, onUpdate);
if (!appLoading) {
onUpdate(type);
onUpdate(groupingKey);
}
return () => {
eUnSubscribeEvent(eGroupOptionsUpdated, onUpdate);
};
}, [type, groupOptions, appLoading]);
}, [groupingKey, appLoading, id, type]);
return groupOptions;
}

View File

@@ -20,14 +20,27 @@ import { ItemType } from "@notesnook/core";
import { useSettingStore } from "../stores/use-setting-store";
export function useIsCompactModeEnabled(dataType: ItemType) {
const [notebooksListMode, notesListMode] = useSettingStore((state) => [
state.settings.notebooksListMode,
state.settings.notesListMode
]);
const [notebooksListMode, notesListMode, searchListMode] = useSettingStore(
(state) => [
state.settings.notebooksListMode,
state.settings.notesListMode,
state.settings.searchListMode
]
);
if (dataType !== "note" && dataType !== "notebook") return false;
if (
dataType !== "note" &&
dataType !== "notebook" &&
dataType !== "searchResult"
)
return false;
const listMode = dataType === "notebook" ? notebooksListMode : notesListMode;
const listMode =
dataType === "notebook"
? notebooksListMode
: dataType === "searchResult"
? searchListMode
: notesListMode;
return listMode === "compact";
}

View File

@@ -17,7 +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 { Plan, SubscriptionPlan } from "@notesnook/core";
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useAsync } from "react-async-hook";
import { Platform } from "react-native";
import Config from "react-native-config";
@@ -181,6 +181,8 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
const [cancelPromo, setCancelPromo] = useState(false);
const [userCanRequestTrial, setUserCanRequestTrial] = useState(false);
const [webPricingPlans, setWebPricingPlans] = useState<Plan[]>([]);
const plansRef = useRef(plans);
plansRef.current = plans;
const getProduct = React.useCallback(
(planId: string, skuId: string) => {
@@ -190,11 +192,11 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
);
return (
plans.find((p) => p.id === planId)?.subscriptions?.[skuId] ||
plans.find((p) => p.id === planId)?.products?.[skuId]
plansRef.current.find((p) => p.id === planId)?.subscriptions?.[skuId] ||
plansRef.current.find((p) => p.id === planId)?.products?.[skuId]
);
},
[plans, webPricingPlans]
[webPricingPlans]
);
const getProductAndroid = (planId: string, skuId: string) => {
@@ -253,35 +255,41 @@ const usePricingPlans = (options?: PricingPlansOptions) => {
useEffect(() => {
const loadPlans = async () => {
const items = await PremiumService.loadProductsAndSubs();
pricingPlans.forEach((plan) => {
plan.subscriptions = {};
plan.products = {};
plan.subscriptionSkuList.forEach((sku) => {
if (!plan.subscriptions) plan.subscriptions = {};
plan.subscriptions[sku] = items.subs.find((p) => p.productId === sku);
try {
const items = await PremiumService.loadProductsAndSubs();
pricingPlans.forEach((plan) => {
plan.subscriptions = {};
plan.products = {};
plan.subscriptionSkuList.forEach((sku) => {
if (!plan.subscriptions) plan.subscriptions = {};
plan.subscriptions[sku] = items.subs.find(
(p) => p.productId === sku
);
});
plan.productSkuList.forEach((sku) => {
if (!plan.products) plan.products = {};
plan.products[sku] = items.products.find(
(p) => p.productId === sku
);
});
});
plan.productSkuList.forEach((sku) => {
if (!plan.products) plan.products = {};
plan.products[sku] = items.products.find((p) => p.productId === sku);
});
});
setPlans([...pricingPlans]);
setUserCanRequestTrial(hasTrialOffer());
if (
Config.GITHUB_RELEASE === "true" &&
!SettingsService.getProperty("serverUrls")
) {
try {
const products = WebPlanCache || (await db.pricing.products());
WebPlanCache = products;
setWebPricingPlans(products);
} catch (e) {
/**
setPlans([...pricingPlans]);
setUserCanRequestTrial(hasTrialOffer());
if (
Config.GITHUB_RELEASE === "true" &&
!SettingsService.getProperty("serverUrls")
) {
try {
const products = WebPlanCache || (await db.pricing.products());
WebPlanCache = products;
setWebPricingPlans(products);
} catch (e) {
/**
empty */
}
}
}
setLoadingPlans(false);
setLoadingPlans(false);
} catch (e) {}
};
loadPlans();
}, [options?.promoOffer, cancelPromo, hasTrialOffer]);

View File

@@ -32,6 +32,8 @@ const useTimer = (initialId?: string) => {
const [id, setId] = useState(initialId);
const [seconds, setSeconds] = useState(getSecondsLeft(id));
const interval = useRef<NodeJS.Timeout>(undefined);
const secondsRef = useRef(seconds);
secondsRef.current = seconds;
const start = (sec: number, currentId = id) => {
if (!currentId) return;
@@ -59,7 +61,7 @@ const useTimer = (initialId?: string) => {
}
};
return { seconds, setId, start, reset };
return { seconds, setId, start, reset, secondsRef: secondsRef };
};
export default useTimer;

View File

@@ -279,6 +279,7 @@ let MoveNotes: any = null;
let Settings: any = null;
let ManageTags: any = null;
let AddReminder: any = null;
let RelationsList: any = null;
let PayWall: any = null;
let Wrapped: any = null;
export const RootNavigation = () => {
@@ -385,6 +386,15 @@ export const RootNavigation = () => {
return AddReminder;
}}
/>
<RootStack.Screen
name="RelationsList"
getComponent={() => {
RelationsList =
RelationsList || require("../screens/relations-list").default;
return RelationsList;
}}
/>
<RootStack.Screen
name="PayWall"
getComponent={() => {

View File

@@ -35,7 +35,6 @@ import { db } from "../../common/database";
import { Dialog } from "../../components/dialog";
import { Header } from "../../components/header";
import { Button } from "../../components/ui/button";
import Input from "../../components/ui/input";
import { ReminderTime } from "../../components/ui/reminder-time";
import Paragraph from "../../components/ui/typography/paragraph";
import { DDS } from "../../services/device-detection";
@@ -59,6 +58,11 @@ import { TimeSince } from "../../components/ui/time-since";
import Heading from "../../components/ui/typography/heading";
import { eOnLoadNote } from "../../utils/events";
import { fluidTabsRef } from "../../utils/global-refs";
import FormInput, {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import AppIcon from "../../components/ui/AppIcon";
const ReminderModes =
Platform.OS === "ios"
@@ -91,7 +95,17 @@ const ReminderNotificationModes = {
export default function AddReminder(props: NavigationProps<"AddReminder">) {
const { reminder, reference } = props.route.params;
useNavigationFocus(props.navigation, { focusOnInit: true });
useNavigationFocus(props.navigation, {
focusOnInit: true,
onFocus: () => {
if (!props.route.params?.reminder) {
setTimeout(() => {
titleRef.current?.focus();
}, 200);
}
return false;
}
});
const { colors, isDark } = useThemeColors();
const weekFormat = useSettingStore((state) => state.weekFormat);
const [reminderMode, setReminderMode] = useState<Reminder["mode"]>(
@@ -113,7 +127,12 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
const [repeatFrequency, setRepeatFrequency] = useState(1);
const referencedItem = reference ? (reference as Note) : null;
const recurringReminderFeature = useIsFeatureAvailable("recurringReminders");
const formRef = useRef(
createFormRef({
title: reminder?.title || referencedItem?.title || "",
description: reminder?.description || referencedItem?.headline || ""
})
);
const title = useRef<string | undefined>(
!reminder ? referencedItem?.title : reminder?.title
);
@@ -132,6 +151,8 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
: null,
[reminder?.id]
);
const [dateError, setDateError] = useState<string>();
const [selectDayError, setSelectDayError] = useState<string>();
const showDatePicker = () => {
setDatePickerVisibility(true);
@@ -143,6 +164,7 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
const handleConfirm = (date: Date) => {
timer.current = setTimeout(() => {
setDateError(undefined);
hideDatePicker();
setDate(date);
}, 10);
@@ -172,26 +194,27 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
async function saveReminder() {
try {
if (!(await Notifications.checkAndRequestPermissions(true)))
throw new Error(strings.noNotificationPermission());
if (!date && reminderMode !== ReminderModes.Permanent) return;
if (!formRef.current.validate()) return;
if (date.getTime() < Date.now() && reminderMode === "once") {
setDateError(strings.dateError());
return;
}
if (
reminderMode === ReminderModes.Repeat &&
recurringMode !== "day" &&
recurringMode !== "year" &&
selectedDays.length === 0
)
throw new Error(strings.selectDayError());
if (!title.current) throw new Error(strings.setTitleError());
if (
date.getTime() < Date.now() &&
reminderMode === "once" &&
!props.route.params.reminder
) {
throw new Error(strings.dateError());
setSelectDayError(strings.selectDayError());
return;
}
if (!date && reminderMode !== ReminderModes.Permanent) return;
if (!(await Notifications.checkAndRequestPermissions(true)))
throw new Error(strings.noNotificationPermission());
date.setSeconds(0, 0);
const reminderId = await db.reminders?.add({
@@ -261,21 +284,26 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
keyboardDismissMode="interactive"
keyboardShouldPersistTaps="handled"
>
<Input
<FormInput
name="title"
validators={[validators.required(strings.titleIsRequired())]}
formRef={formRef}
fwdRef={titleRef}
defaultValue={reminder?.title || referencedItem?.title}
placeholder={strings.remindeMeOf()}
onChangeText={(text) => (title.current = text)}
autoFocus
wrapperStyle={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
onSubmit={() => {
onSubmitEditing={() => {
descriptionRef.current?.focus();
}}
/>
<Input
<FormInput
name="description"
validators={[]}
formRef={formRef}
defaultValue={
reminder ? reminder?.description : referencedItem?.headline
}
@@ -461,6 +489,22 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
/>
))}
</ScrollView>
{selectDayError ? (
<Paragraph
size={AppFontSize.xs}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
color: colors.error.icon
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{selectDayError}
</Paragraph>
) : null}
</View>
) : null}
@@ -476,16 +520,23 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
<DateTimePickerModal
isVisible={isDatePickerVisible}
mode="datetime"
minimumDate={
reminderMode === "once" ? dayjs().toDate() : new Date(0)
}
onConfirm={handleConfirm}
onCancel={hideDatePicker}
isDarkModeEnabled={isDark}
firstDayOfWeek={weekFormat === "Mon" ? 1 : 0}
is24Hour={db.settings.getTimeFormat() === "24-hour"}
date={date || new Date(Date.now())}
themeVariant={isDark ? "dark" : "light"}
/>
<DatePicker
date={date}
minimumDate={
reminderMode === "once" ? dayjs().toDate() : new Date(0)
}
maximumDate={dayjs(date).add(3, "months").toDate()}
onDateChange={handleConfirm}
theme={isDark ? "dark" : "light"}
@@ -519,6 +570,23 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
}}
/>
)}
{dateError ? (
<Paragraph
size={AppFontSize.xs}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL,
color: colors.error.icon
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{dateError}
</Paragraph>
) : null}
</View>
)}

View File

@@ -70,6 +70,7 @@ export const Archive = ({ navigation, route }: NavigationProps<"Archive">) => {
onRefresh={() => {
refresh();
}}
groupType="archive"
renderedInRoute="Archive"
loading={loading}
placeholder={{

View File

@@ -46,7 +46,6 @@ import EditorTabs from "../../../components/sheets/editor-tabs";
import { Issue } from "../../../components/sheets/github/issue";
import LinkNote from "../../../components/sheets/link-note";
import PaywallSheet from "../../../components/sheets/paywall";
import { RelationsList } from "../../../components/sheets/relations-list";
import TableOfContents from "../../../components/sheets/toc";
import { DDS } from "../../../services/device-detection";
import {
@@ -80,6 +79,7 @@ import { fluidTabsRef } from "../../../utils/global-refs";
import { sleep } from "../../../utils/time";
import AddReminder from "../../add-reminder";
import ManageTags from "../../manage-tags";
import RelationsList from "../../relations-list";
import { useDragState } from "../../settings/editor/state";
import { EditorMessage, EditorProps, useEditorType } from "./types";
import { useTabStore } from "./use-tab-store";
@@ -466,7 +466,7 @@ export const useEditorEvents = (
const note = await db.notes.note(noteId);
if (!note) return;
RelationsList.present({
reference: note as any,
item: note as any,
referenceType: "reminder",
relationType: "from",
title: strings.dataTypesPluralCamelCase.reminder(),

View File

@@ -70,6 +70,7 @@ export const Favorites = ({
<List
data={favorites}
dataType="note"
groupType="favorites"
onRefresh={() => {
refresh();
}}

View File

@@ -70,6 +70,7 @@ export const Home = ({ navigation, route }: NavigationProps<"Notes">) => {
<List
data={notes}
dataType="note"
groupType="home"
renderedInRoute={route.name}
loading={loading || !isFocused}
headerTitle={strings.routes[route.name]()}

View File

@@ -41,6 +41,7 @@ import { View } from "react-native";
import { Notebooks } from "../../components/sheets/notebooks";
import { useSettingStore } from "../../stores/use-setting-store";
import { rootNavigatorRef } from "../../utils/global-refs";
import { getGroupOptions } from "../../hooks/use-group-options";
const NotebookScreen = ({ route, navigation }: NavigationProps<"Notebook">) => {
const [notes, setNotes] = useState<VirtualizedGrouping<Note>>();
@@ -120,7 +121,9 @@ const NotebookScreen = ({ route, navigation }: NavigationProps<"Notebook">) => {
params.current.id = notebook.id;
const notes = await db.relations
.from(notebook, "note")
.selector.grouped(db.settings.getGroupOptions("notes"));
.selector.grouped(
getGroupOptions("notes", notebook.id, "notebook")
);
setNotes(notes);
await notes.item(0, resolveItems);
syncWithNavigation();
@@ -185,10 +188,12 @@ const NotebookScreen = ({ route, navigation }: NavigationProps<"Notebook">) => {
<List
data={notes}
dataType="note"
groupType="notes"
onRefresh={() => {
onRequestUpdate();
}}
id={params.current?.id}
type="notebook"
renderedInRoute="Notebook"
headerTitle={notebook?.title}
loading={loading}

View File

@@ -26,6 +26,7 @@ import useNavigationStore, {
NotesScreenParams
} from "../../stores/use-navigation-store";
import { PLACEHOLDER_DATA, openEditor } from "./common";
import { getGroupOptions } from "../../hooks/use-group-options";
export const ColoredNotes = ({
navigation,
route
@@ -52,7 +53,7 @@ ColoredNotes.get = async (params: NotesScreenParams, grouped = true) => {
return await db.relations
.from({ id: params.id, type: "color" }, "note")
.selector.grouped(db.settings.getGroupOptions("notes"));
.selector.grouped(getGroupOptions("notes", params.id, "color"));
};
ColoredNotes.navigate = (item: Color, canGoBack: boolean) => {

View File

@@ -18,8 +18,8 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { resolveItems } from "@notesnook/common";
import { Tag, VirtualizedGrouping } from "@notesnook/core";
import { Color, Note } from "@notesnook/core";
import { Color, Note, Tag, VirtualizedGrouping } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import React, { useEffect, useRef, useState } from "react";
import { db } from "../../common/database";
import { FloatingButton } from "../../components/container/floating-button";
@@ -39,10 +39,9 @@ import useNavigationStore, {
NotesScreenParams,
RouteName
} from "../../stores/use-navigation-store";
import { setOnFirstSave } from "./common";
import { strings } from "@notesnook/intl";
import { useSettingStore } from "../../stores/use-setting-store";
import { rootNavigatorRef } from "../../utils/global-refs";
import { setOnFirstSave } from "./common";
export interface RouteProps<T extends RouteName> extends NavigationProps<T> {
get: (
@@ -172,7 +171,7 @@ const NotesPage = ({
if (loadingNotes) {
onRequestUpdate(params.current);
}
}, [loadingNotes, get, isAppLoading]);
}, [loadingNotes, get, isAppLoading, onRequestUpdate]);
useEffect(() => {
eSubscribeEvent(route.name, onRequestUpdate);
@@ -193,11 +192,14 @@ const NotesPage = ({
hasSearch={true}
id={route.name === "Monographs" ? "Monographs" : params?.current?.id}
onSearch={() => {
if (!item) return;
if (route.name !== "Monographs" && !item) return;
const selector =
route.name === "Monographs"
? db.monographs.all
: db.relations.from(item, "note").selector;
: item && db.relations.from(item, "note").selector;
if (!selector) return;
Navigation.push("Search", {
placeholder: strings.searchInRoute(title || route.name),
@@ -214,10 +216,12 @@ const NotesPage = ({
<List
data={notes}
dataType="note"
groupType="notes"
onRefresh={onRequestUpdate}
loading={false}
renderedInRoute={route.name}
id={params.current?.id}
type={params.current?.item?.type}
headerTitle={title || "Monographs"}
customAccentColor={accentColor}
placeholder={placeholder}

View File

@@ -26,6 +26,7 @@ import useNavigationStore, {
NotesScreenParams
} from "../../stores/use-navigation-store";
import { PLACEHOLDER_DATA, openEditor } from "./common";
import { getGroupOptions } from "../../hooks/use-group-options";
export const TaggedNotes = ({
navigation,
@@ -53,7 +54,7 @@ TaggedNotes.get = async (params: NotesScreenParams, grouped = true) => {
return await db.relations
.from({ id: params.id, type: "tag" }, "note")
.selector.grouped(db.settings.getGroupOptions("notes"));
.selector.grouped(getGroupOptions("notes", params.id, "tag"));
};
TaggedNotes.navigate = (item: Tag, canGoBack?: boolean) => {

View File

@@ -0,0 +1,142 @@
/*
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 { Item, ItemReference, VirtualizedGrouping } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useState } from "react";
import { View } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { db } from "../../common/database";
import { Header } from "../../components/header";
import List from "../../components/list";
import { Button } from "../../components/ui/button";
import Paragraph from "../../components/ui/typography/paragraph";
import Navigation, { NavigationProps } from "../../services/navigation";
import { useRelationStore } from "../../stores/use-relation-store";
import { AppFontSize } from "../../utils/size";
type RelationsListProps = {
item: Item;
referenceType: "notebook" | "tag" | "reminder" | "note";
relationType: "to" | "from";
title: string;
onAdd?: () => void;
};
const IconsByType = {
reminder: "bell"
};
function RelationsList(props: NavigationProps<"RelationsList">) {
const { item, referenceType, relationType, title, onAdd } = props.route
.params as RelationsListProps;
const updater = useRelationStore((state) => state.updater);
const { colors } = useThemeColors();
const [items, setItems] = useState<VirtualizedGrouping<Item>>();
const hasNoRelations = !items || items?.placeholders?.length === 0;
useEffect(() => {
db.relations?.[relationType]?.(
{ id: item?.id, type: item?.type } as ItemReference,
referenceType
)
.selector.sorted({
sortBy: "dateEdited",
sortDirection: "desc"
})
.then((grouped) => {
setTimeout(() => {
setItems(grouped);
}, 300);
});
}, [relationType, referenceType, item?.id, item?.type, updater]);
return (
<SafeAreaView
style={{
flex: 1,
backgroundColor: colors.primary.background,
width: "100%"
}}
>
<Header title={title} canGoBack />
<View style={{ flex: 1 }}>
{hasNoRelations ? (
<View
style={{
height: "85%",
justifyContent: "center",
alignItems: "center"
}}
>
<Icon
name={IconsByType[referenceType as keyof typeof IconsByType]}
size={60}
color={colors.primary.icon}
/>
<Paragraph>{strings.noLinksFound()}</Paragraph>
<Button
onPress={onAdd}
fontSize={AppFontSize.sm}
type="inverted"
icon="plus"
title={strings.addItem(referenceType)}
/>
</View>
) : (
<List
data={items}
loading={false}
groupType={
referenceType === "note"
? "notes"
: referenceType === "tag"
? "tags"
: referenceType === "notebook"
? "notebooks"
: referenceType === "reminder"
? "reminders"
: "notes"
}
dataType={referenceType}
/>
)}
</View>
</SafeAreaView>
);
}
RelationsList.present = ({
item,
relationType,
referenceType,
title,
onAdd
}: RelationsListProps) => {
Navigation.navigate("RelationsList", {
item,
relationType,
referenceType,
title,
onAdd
});
};
export default RelationsList;

View File

@@ -90,6 +90,7 @@ export const Reminders = ({
<List
data={reminders}
dataType="reminder"
groupType="reminders"
headerTitle={strings.routes[route.name]()}
renderedInRoute="Reminders"
loading={loading}
@@ -98,9 +99,8 @@ export const Reminders = ({
paragraph: strings.remindersEmpty(),
button: strings.setReminder(),
action: async () => {
const reminderFeature = await isFeatureAvailable(
"activeReminders"
);
const reminderFeature =
await isFeatureAvailable("activeReminders");
if (!reminderFeature.isAllowed) {
ToastManager.show({
type: "info",

View File

@@ -154,6 +154,7 @@ export const Search = ({ route, navigation }: NavigationProps<"Search">) => {
data={results}
dataType={route.params?.type}
renderedInRoute={route.name}
groupType="search"
loading={loading}
placeholder={{
title: route.name,

View File

@@ -21,6 +21,7 @@ import { sanitizeFilename, useIsFeatureAvailable } from "@notesnook/common";
import { strings } from "@notesnook/intl";
import { useThemeColors, VariantsWithStaticColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
import isMobilePhone from "validator/lib/isMobilePhone";
import React, {
Dispatch,
SetStateAction,
@@ -28,7 +29,13 @@ import React, {
useRef,
useState
} from "react";
import { ActivityIndicator, Linking, Platform, View } from "react-native";
import {
ActivityIndicator,
Linking,
Platform,
TextInput,
View
} from "react-native";
import RNFetchBlob from "react-native-blob-util";
import { FlatList } from "react-native-gesture-handler";
import * as ScopedStorage from "react-native-scoped-storage";
@@ -37,7 +44,10 @@ import filesystem from "../../common/filesystem";
import DialogHeader from "../../components/dialog/dialog-header";
import { Button } from "../../components/ui/button";
import { IconButton } from "../../components/ui/icon-button";
import Input from "../../components/ui/input";
import FormInput, {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import { Pressable } from "../../components/ui/pressable";
import Seperator from "../../components/ui/seperator";
import { SvgView } from "../../components/ui/svg";
@@ -183,27 +193,60 @@ export const MFASetup = ({
}: MFAStepProps) => {
const { colors } = useThemeColors();
const user = useUserStore((state) => state.user);
const methodId = method?.id;
const formRef = useRef(
createFormRef({
target: "",
code: ""
})
);
const targetInputRef = useRef<TextInput>(null);
const codeInputRef = useRef<TextInput>(null);
const [authenticatorDetails, setAuthenticatorDetails] = useState({
sharedKey: null,
authenticatorUri: null
});
const code = useRef<string>(undefined);
const phoneNumber = useRef<string>(undefined);
const { seconds, setId, start } = useTimer(method?.id);
const [loading, setLoading] = useState(method?.id === "app" ? true : false);
const [enabling, setEnabling] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState(false);
const [generalError, setGeneralError] = useState<string>();
useEffect(() => {
if (method?.id === "app") {
db.mfa?.setup("app").then((data) => {
setAuthenticatorDetails(data);
setLoading(false);
});
if (methodId === "app") {
setLoading(true);
db.mfa
?.setup("app")
.then((data) => {
setAuthenticatorDetails(data);
setLoading(false);
})
.catch((error: Error) => {
setLoading(false);
setGeneralError(error.message);
});
return;
}
}, [method?.id]);
setLoading(false);
}, [methodId]);
useEffect(() => {
if (!methodId) return;
formRef.current.clearErrors();
formRef.current.setValue(
"target",
methodId === "email"
? user?.email || ""
: methodId === "app"
? authenticatorDetails.sharedKey || ""
: formRef.current.getValue("target")
);
formRef.current.setValue("code", "");
setGeneralError(undefined);
}, [authenticatorDetails.sharedKey, methodId, user?.email]);
const codeHelpText = {
app: "After putting the above code in authenticator app, the app will display a code that you can enter below.",
@@ -212,15 +255,43 @@ export const MFASetup = ({
"You will receive a 2FA code on your email address which you can enter below"
};
const targetValidators =
method?.id === "sms"
? [
validators.required(strings.phoneNumberNotEntered()),
(value: string) =>
isMobilePhone(value, "any", {
strictMode: true
})
? undefined
: strings.enterValidPhone()
]
: method?.id === "email"
? [
validators.required(strings.emailRequired()),
validators.email(strings.enterValidEmail())
]
: [];
const codeValidators = [
validators.required(strings.enterSixDigitCode()),
(value: string) =>
/^\d{6}$/.test(value.trim()) ? undefined : strings.enterSixDigitCode()
];
const onNext = async () => {
if (!code.current || code.current.length !== 6) return;
if (formRef.current.validateField("code")) return;
try {
if (!method) return;
const code = formRef.current.getValue("code").trim();
setGeneralError(undefined);
setEnabling(true);
if (recovery) {
await db.mfa.enableFallback(method.id, code.current);
await db.mfa.enableFallback(method.id, code);
} else {
await db.mfa.enable(method.id, code.current);
await db.mfa.enable(method.id, code);
}
const user = await db.user.fetchUser();
@@ -229,14 +300,18 @@ export const MFASetup = ({
setEnabling(false);
} catch (e) {
const error = e as Error;
ToastManager.error(error, "Error submitting 2fa code");
formRef.current.setError("code", error.message);
setEnabling(false);
}
};
const onSendCode = async () => {
if (error) return;
if (!method || sending) return;
if (method.id !== "app" && formRef.current.validateField("target")) {
return;
}
if (method.id === "app" && authenticatorDetails.sharedKey) {
Clipboard.setString(authenticatorDetails.sharedKey);
if (authenticatorDetails.authenticatorUri) {
@@ -254,30 +329,37 @@ export const MFASetup = ({
}
try {
if (seconds) throw new Error(strings.resendCodeWait());
if (method.id === "sms" && !phoneNumber.current)
throw new Error(strings.phoneNumberNotEntered());
const target = formRef.current.getValue("target").trim();
setGeneralError(undefined);
if (seconds) {
setGeneralError(strings.resendCodeWait());
return;
}
setSending(true);
await db.mfa.setup(method?.id, phoneNumber.current);
await db.mfa.setup(method.id, method.id === "sms" ? target : undefined);
if (method.id === "sms") {
setId(method.id + phoneNumber.current);
setId(method.id + target);
}
await sleep(300);
start(
60,
method.id === "sms" ? method.id + phoneNumber.current : method.id
);
start(60, method.id === "sms" ? method.id + target : method.id);
setSending(false);
ToastManager.show({
heading: strings["2faCodeSentVia"](method.id),
type: "success",
context: "local"
});
codeInputRef.current?.focus();
} catch (e) {
setSending(false);
const error = e as Error;
ToastManager.error(error, strings.errorSend2fa());
if (method.id === "sms" || method.id === "email") {
formRef.current.setError("target", error.message);
} else {
setGeneralError(error.message);
}
}
};
@@ -316,55 +398,54 @@ export const MFASetup = ({
</View>
) : (
<>
<Input
loading={method?.id !== "sms" ? true : false}
value={
method?.id === "email"
? user?.email
: method?.id === "app"
? authenticatorDetails?.sharedKey || ""
: undefined
<FormInput
key={`${method.id}-${authenticatorDetails.sharedKey || user?.email || ""}`}
name="target"
formRef={formRef}
fwdRef={targetInputRef}
loading={method?.id !== "sms"}
editable={method.id === "sms"}
defaultValue={
method.id === "email"
? user?.email || ""
: method.id === "app"
? authenticatorDetails.sharedKey || ""
: undefined
}
multiline={method.id === "app"}
onChangeText={(value) => {
phoneNumber.current = value;
onChangeText={() => {
setGeneralError(undefined);
}}
placeholder={
method?.id === "email"
method.id === "email"
? strings.enterEmailAddress()
: "+1234567890"
}
onSubmit={() => {
onSendCode();
}}
onErrorCheck={(e) => setError(e)}
validationType={method?.id === "email" ? "email" : "phonenumber"}
onSubmitEditing={onSendCode}
validators={targetValidators}
keyboardType={
method.id == "email" ? "email-address" : "phone-pad"
}
errorMessage={
method?.id === "email"
? strings.enterValidEmail()
: strings.enterValidPhone()
method.id === "email" ? "email-address" : "phone-pad"
}
buttons={
error ? null : (
<Button
onPress={onSendCode}
loading={sending}
title={
sending
? null
: method.id === "app"
<Button
onPress={onSendCode}
loading={sending}
style={{
paddingVertical: 0,
paddingHorizontal: 0
}}
title={
sending
? null
: method.id === "app"
? strings.copy()
: `${
seconds
? strings.resendCode(seconds as number)
: strings.sendCode()
}`
}
/>
)
}
/>
}
/>
@@ -373,13 +454,22 @@ export const MFASetup = ({
</Heading>
<Paragraph>{codeHelpText[method?.id]}</Paragraph>
<Seperator />
<Input
<FormInput
name="code"
formRef={formRef}
fwdRef={codeInputRef}
placeholder="xxxxxx"
maxLength={6}
loading={loading}
textAlign="center"
keyboardType="numeric"
onChangeText={(value) => (code.current = value)}
onChangeText={() => {
setGeneralError(undefined);
}}
onSubmitEditing={onNext}
returnKeyLabel={strings.next()}
returnKeyType="done"
validators={codeValidators}
inputStyle={{
fontSize: AppFontSize.lg,
height: 60,
@@ -392,7 +482,25 @@ export const MFASetup = ({
borderWidth: 0,
width: undefined
}}
errorStyle={{
textAlign: "center"
}}
/>
{generalError ? (
<Paragraph
size={AppFontSize.sm}
style={{
color: colors.error.icon,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
textAlign: "center",
width: "100%"
}}
>
{generalError}
</Paragraph>
) : null}
<Seperator />
<Button
title={enabling ? null : strings.next()}
@@ -517,7 +625,7 @@ export const MFARecoveryCodes = ({
ToastManager.show({
heading: strings.codesCopied(),
type: "success",
context: "global"
context: "local"
});
}}
style={{

View File

@@ -16,15 +16,20 @@ 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 { TextInput, View } from "react-native";
import { db } from "../../../common/database";
import { eSendEvent, ToastManager } from "../../../services/event-manager";
import { eUserLoggedIn } from "../../../utils/events";
import { strings } from "@notesnook/intl";
import { DefaultAppStyles } from "../../../utils/styles";
import Input from "../../../components/ui/input";
import { Button } from "../../../components/ui/button";
import FormInput, {
createFormRef,
validators
} from "../../../components/ui/input/form-input";
import { eSendEvent, ToastManager } from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
import { eUserLoggedIn } from "../../../utils/events";
import { DefaultAppStyles } from "../../../utils/styles";
enum EmailChangeSteps {
verify,
@@ -32,14 +37,16 @@ enum EmailChangeSteps {
}
export const ChangeEmail = () => {
const { colors } = useThemeColors();
const [step, setStep] = useState(EmailChangeSteps.verify);
const emailChangeData = useRef<{
email?: string;
password?: string;
code?: string;
}>({});
const formRef = useRef(
createFormRef({
email: "",
password: "",
code: ""
})
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const emailInputRef = useRef<TextInput>(null);
const passInputRef = useRef<TextInput>(null);
const codeInputRef = useRef<TextInput>(null);
@@ -47,44 +54,52 @@ export const ChangeEmail = () => {
const onSubmit = async () => {
try {
if (step === EmailChangeSteps.verify) {
if (
!emailChangeData.current.email ||
!emailChangeData.current.password ||
error
)
return;
const hasEmailError = formRef.current.validateField("email");
const hasPasswordError = formRef.current.validateField("password");
if (hasEmailError || hasPasswordError) return;
const { email, password } = formRef.current.getValues();
setLoading(true);
const verified = await db.user?.verifyPassword(
emailChangeData.current.password
);
const verified = await db.user?.verifyPassword(password);
if (!verified) throw new Error(strings.passwordIncorrect());
await db.user?.sendVerificationEmail(emailChangeData.current.email);
await db.user?.sendVerificationEmail(email);
setStep(EmailChangeSteps.changeEmail);
formRef.current.clearErrors();
formRef.current.setValue("code", "");
setLoading(false);
} else {
if (
!emailChangeData.current.email ||
!emailChangeData.current.password ||
error ||
!emailChangeData.current.code
)
return;
await db.user?.changeEmail(
emailChangeData.current.email,
emailChangeData.current.password,
emailChangeData.current.code
);
const hasCodeError = formRef.current.validateField("code");
if (hasCodeError) return;
const { email, password, code } = formRef.current.getValues();
setLoading(true);
await db.user?.changeEmail(email, password, code);
eSendEvent(eUserLoggedIn);
close?.();
ToastManager.show({
heading: strings.emailUpdated(emailChangeData.current.email),
heading: strings.emailUpdated(email),
type: "success",
context: "global"
});
Navigation.goBack();
setLoading(false);
}
} catch (e) {
setLoading(false);
ToastManager.error(e as Error);
const error = e as Error;
if (step === EmailChangeSteps.verify) {
if (error.message === strings.passwordIncorrect()) {
formRef.current.setError("password", error.message);
} else {
formRef.current.setError("email", error.message);
}
return;
} else {
formRef.current.setError("code", error.message);
return;
}
}
};
@@ -97,34 +112,54 @@ export const ChangeEmail = () => {
>
{step === EmailChangeSteps.verify ? (
<>
<Input
<FormInput
name="email"
formRef={formRef}
fwdRef={emailInputRef}
placeholder={strings.enterNewEmail()}
validationType="email"
onErrorCheck={(e) => setError(e)}
onChangeText={(email) => {
emailChangeData.current.email = email;
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
validators={[
validators.required(strings.emailRequired()),
validators.email(strings.enterValidEmail())
]}
onSubmitEditing={() => {
passInputRef.current?.focus();
}}
/>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passInputRef}
placeholder={strings.enterAccountPassword()}
secureTextEntry
onChangeText={(pass) => {
emailChangeData.current.password = pass;
}}
autoCapitalize="none"
autoCorrect={false}
autoComplete="password"
validators={[validators.required(strings.passwordRequired())]}
onSubmitEditing={onSubmit}
/>
</>
) : (
<>
<Input
key="code-input"
<FormInput
name="code"
formRef={formRef}
fwdRef={codeInputRef}
placeholder={strings.code()}
defaultValue=""
onChangeText={(code) => {
emailChangeData.current.code = code;
}}
keyboardType="number-pad"
autoCapitalize="none"
autoCorrect={false}
maxLength={6}
validators={[
validators.required(strings.enterSixDigitCode()),
(value: string) =>
/^\d{6}$/.test(value.trim())
? undefined
: strings.enterSixDigitCode()
]}
onSubmitEditing={onSubmit}
/>
</>
)}
@@ -135,8 +170,8 @@ export const ChangeEmail = () => {
loading
? undefined
: step === EmailChangeSteps.verify
? strings.verify()
: strings.changeEmail()
? strings.verify()
: strings.changeEmail()
}
type="accent"
style={{

View File

@@ -47,6 +47,14 @@ export async function verifyUser(
negativeText: closeText || strings.cancel(),
positivePress: async (value) => {
try {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const user = await db.user.getUser();
let verified = !user ? true : await db.user.verifyPassword(value);
if (verified) {
@@ -95,6 +103,14 @@ export async function verifyUserWithApplock() {
keyboardType: keyboardType,
positivePress: async (value) => {
try {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const verified = await validateAppLockPassword(value);
if (!verified) {
ToastManager.show({

View File

@@ -31,11 +31,10 @@ import dayjs from "dayjs";
import React from "react";
import { Appearance, Linking, Platform } from "react-native";
import { getVersion } from "react-native-device-info";
import { TextInput } from "react-native-gesture-handler";
import * as RNIap from "react-native-iap";
//@ts-ignore
import { enabled } from "react-native-privacy-snapshot";
import ScreenGuardModule from "react-native-screenguard";
import { DatabaseLogger, db } from "../../common/database";
import { MMKV } from "../../common/database/mmkv";
import filesystem from "../../common/filesystem";
import { presentDialog } from "../../components/dialog/functions";
import { AppLockPassword } from "../../components/dialogs/applock-password";
@@ -44,43 +43,45 @@ import ExportNotesSheet from "../../components/sheets/export-notes";
import { Issue } from "../../components/sheets/github/issue";
import { Progress } from "../../components/sheets/progress";
import { Update } from "../../components/sheets/update";
import {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import { VaultStatusType, useVaultStatus } from "../../hooks/use-vault-status";
import { BackgroundSync } from "../../services/background-sync";
import BackupService from "../../services/backup";
import BiometricService from "../../services/biometrics";
import {
ToastManager,
VaultRequestType,
eSendEvent,
eSubscribeEvent,
openVault,
presentSheet,
VaultRequestType
presentSheet
} from "../../services/event-manager";
import Navigation from "../../services/navigation";
import Notifications from "../../services/notifications";
import PremiumService from "../../services/premium";
import SettingsService from "../../services/settings";
import Sync from "../../services/sync";
import { clearAllStores } from "../../stores";
import { refreshAllStores } from "../../stores/create-db-collection-store";
import { useThemeStore } from "../../stores/use-theme-store";
import { useUserStore } from "../../stores/use-user-store";
import { EDITOR_LINE_HEIGHT } from "../../utils/constants";
import {
eAfterSync,
eCloseSheet,
eOpenRecoveryKeyDialog
} from "../../utils/events";
import { NotesnookModule } from "../../utils/notesnook-module";
import { sleep } from "../../utils/time";
import { resetTabStore } from "../editor/tiptap/use-tab-store";
import { MFARecoveryCodes, MFASheet } from "./2fa";
import { useDragState } from "./editor/state";
import { verifyUser, verifyUserWithApplock } from "./functions";
import { logoutUser } from "./logout";
import { SettingSection } from "./types";
import { getTimeLeft } from "./user-section";
import { EDITOR_LINE_HEIGHT } from "../../utils/constants";
import { MMKV } from "../../common/database/mmkv";
import { resetTabStore } from "../editor/tiptap/use-tab-store";
import { clearAllStores } from "../../stores";
import { refreshAllStores } from "../../stores/create-db-collection-store";
export const settingsGroups: SettingSection[] = [
{
@@ -206,15 +207,15 @@ export const settingsGroups: SettingSection[] = [
return status === SubscriptionStatus.TRIAL
? strings.trialOnGoing(trialEndDate)
: status === SubscriptionStatus.ACTIVE
? strings.subRenewOn(expiryDate)
: status === SubscriptionStatus.CANCELED ||
status === SubscriptionStatus.PAUSED
? strings.subEndsOn(expiryDate)
: status === SubscriptionStatus.EXPIRED
? subscriptionDaysLeft.time < -3
? strings.subEnded()
: strings.accountDowngradedIn(3)
: strings.neverHesitate();
? strings.subRenewOn(expiryDate)
: status === SubscriptionStatus.CANCELED ||
status === SubscriptionStatus.PAUSED
? strings.subEndsOn(expiryDate)
: status === SubscriptionStatus.EXPIRED
? subscriptionDaysLeft.time < -3
? strings.subEnded()
: strings.accountDowngradedIn(3)
: strings.neverHesitate();
}
return strings.neverHesitate();
@@ -236,18 +237,29 @@ export const settingsGroups: SettingSection[] = [
presentDialog({
title: strings.redeemGiftCode(),
paragraph: strings.redeemGiftCodeDesc(),
input: true,
inputPlaceholder: strings.code(),
positiveText: strings.redeem(),
positivePress: async (value) => {
db.subscriptions.redeemCode(value).catch((e) => {
ToastManager.show({
heading: "Error redeeming code",
message: (e as Error).message,
type: "error"
});
});
}
form: {
formRef: createFormRef({
code: ""
}),
items: [
{
name: "code",
placeholder: strings.code(),
ref: React.createRef<TextInput | null>(),
validators: [validators.required(strings.giftCodeRequired())]
}
],
onFormSubmit: async (form) => {
try {
await db.subscriptions.redeemCode(form.getValue("code"));
return true;
} catch (e) {
form.setError("code", (e as Error).message);
return false;
}
}
},
positiveText: strings.redeem()
});
}
},
@@ -417,36 +429,61 @@ export const settingsGroups: SettingSection[] = [
{
id: "subscription-not-active",
name: strings.subscriptionNotActivated(),
hidden: () => Platform.OS !== "ios",
useHook: () => useUserStore((state) => state.user),
hidden: (user) =>
Platform.OS !== "ios" ||
(user as User)?.subscription?.plan !== SubscriptionPlan.FREE,
modifer: async () => {
if (Platform.OS === "android") return;
presentSheet({
title: strings.loadingSubscription(),
paragraph: strings.loadingSubscriptionDesc()
});
const subscriptions = await RNIap.getPurchaseHistory();
subscriptions.sort(
(a, b) => b.transactionDate - a.transactionDate
);
const currentSubscription = subscriptions[0];
presentSheet({
title: strings.notesnookPro(),
paragraph: strings.subscribedOnVerify(
new Date(currentSubscription.transactionDate).toLocaleString()
),
action: async () => {
presentSheet({
title: strings.verifySubscription(),
paragraph: strings.subscriptionVerifyWait()
try {
presentSheet({
title: strings.loadingSubscription(),
paragraph: strings.loadingSubscriptionDesc(),
progress: true
});
const subscriptions = await RNIap.getPurchaseHistory();
subscriptions.sort(
(a, b) => b.transactionDate - a.transactionDate
);
const currentSubscription = subscriptions[0];
if (
!currentSubscription ||
dayjs(currentSubscription.transactionDate).isBefore(
dayjs().subtract(30, "day")
)
) {
ToastManager.show({
message: "No active subscription found",
type: "info"
});
await PremiumService.subscriptions.verify(
currentSubscription
);
eSendEvent(eCloseSheet);
},
icon: "information-outline",
actionText: strings.verify()
});
return;
}
presentSheet({
title: strings.notesnookPro(),
paragraph: strings.subscribedOnVerify(
new Date(
currentSubscription.transactionDate
).toLocaleString()
),
action: async () => {
presentSheet({
title: strings.verifySubscription(),
paragraph: strings.subscriptionVerifyWait()
});
await PremiumService.subscriptions.verify(
currentSubscription
);
eSendEvent(eCloseSheet);
},
icon: "information-outline",
actionText: strings.verify()
});
} catch (e) {
eSendEvent(eCloseSheet);
}
},
description: strings.verifySubDesc()
},
@@ -517,6 +554,14 @@ export const settingsGroups: SettingSection[] = [
positiveText: strings.delete(),
positivePress: async (value) => {
try {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const verified = await db.user?.verifyPassword(value);
if (verified) {
setTimeout(async () => {
@@ -1054,19 +1099,7 @@ export const settingsGroups: SettingSection[] = [
description: strings.privacyModeDesc(),
modifer: () => {
const settings = SettingsService.get();
if (Platform.OS === "ios") {
enabled(!settings.privacyScreen);
if (settings.privacyScreen) {
ScreenGuardModule.unregister();
} else {
ScreenGuardModule.register({
backgroundColor: "#000000"
});
}
} else {
NotesnookModule.setSecureMode(!settings.privacyScreen);
}
SettingsService.setPrivacyScreen(!settings.privacyScreen);
SettingsService.set({ privacyScreen: !settings.privacyScreen });
},
property: "privacyScreen"
@@ -1087,11 +1120,13 @@ export const settingsGroups: SettingSection[] = [
type: "switch",
property: "appLockEnabled",
featureId: "appLock",
onChange: () => {
SettingsService.set({
privacyScreen: true
});
SettingsService.setPrivacyScreen(SettingsService.get());
onChange: (property) => {
if (property) {
SettingsService.set({
privacyScreen: true
});
SettingsService.setPrivacyScreen(true);
}
},
onVerify: async () => {
const verified = await verifyUserWithApplock();

View File

@@ -31,7 +31,11 @@ import type {
ThemesRouter
} from "@notesnook/themes-server";
import { keepLocalCopy, pick } from "@react-native-documents/picker";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
notifyManager,
QueryClient,
QueryClientProvider
} from "@tanstack/react-query";
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import { createTRPCReact } from "@trpc/react-query";
import React, { useState } from "react";
@@ -69,7 +73,8 @@ export const themeTrpcClient = createTRPCProxyClient<ThemesRouter>({
})
]
});
notifyManager.setBatchNotifyFunction((cb) => cb());
notifyManager.setNotifyFunction((cb) => cb());
function ThemeSelector() {
const [darkTheme, lightTheme] = useThemeStore((state) => [
state.darkTheme,
@@ -80,30 +85,20 @@ function ThemeSelector() {
const themeColors = colors;
const [searchQuery, setSearchQuery] = useState<string>();
const [colorScheme, setColorScheme] = useState<string>();
const filters = [];
if (searchQuery) filters.push({ type: "term" as const, value: searchQuery });
if (colorScheme)
filters.push({ type: "colorScheme" as const, value: colorScheme });
const themes = trpc.themes.useInfiniteQuery(
{
limit: 10,
compatibilityVersion: THEME_COMPATIBILITY_VERSION,
filters: [
...(searchQuery && searchQuery !== ""
? [
{
type: "term" as const,
value: searchQuery
}
]
: []),
...(colorScheme && colorScheme !== ""
? [
{
type: "colorScheme" as const,
value: colorScheme
}
]
: [])
]
filters
},
{
keepPreviousData: true,
getNextPageParam: (lastPage) => lastPage.nextCursor
}
);
@@ -366,12 +361,14 @@ function ThemeSelector() {
>
<View
style={{
flexDirection: "row"
flexDirection: "row",
gap: DefaultAppStyles.GAP_SMALL
}}
>
<Button
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
paddingHorizontal: DefaultAppStyles.GAP_SMALL
}}
type={
colorScheme === "" || !colorScheme ? "accent" : "secondary"
@@ -384,7 +381,8 @@ function ThemeSelector() {
/>
<Button
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
paddingHorizontal: DefaultAppStyles.GAP_SMALL
}}
type={colorScheme === "dark" ? "accent" : "secondary"}
title={strings.dark()}
@@ -395,7 +393,8 @@ function ThemeSelector() {
/>
<Button
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
paddingHorizontal: DefaultAppStyles.GAP_SMALL
}}
fontSize={AppFontSize.xs}
type={colorScheme === "light" ? "accent" : "secondary"}
@@ -409,7 +408,8 @@ function ThemeSelector() {
<Button
title={strings.loadFromFile()}
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
paddingHorizontal: DefaultAppStyles.GAP_SMALL
}}
type={"secondaryAccented"}
icon="folder"

View File

@@ -29,6 +29,10 @@ import ImagePicker from "react-native-image-crop-picker";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { db } from "../../common/database";
import { presentDialog } from "../../components/dialog/functions";
import {
createFormRef,
validators
} from "../../components/ui/input/form-input";
import { PlanLimits } from "../../components/sheets/plan-limits";
import AppIcon from "../../components/ui/AppIcon";
import { Button } from "../../components/ui/button";
@@ -197,19 +201,34 @@ const SettingsUserSection = ({ item }) => {
title: strings.setFullName(),
paragraph: strings.setFullNameDesc(),
positiveText: strings.save(),
input: true,
inputPlaceholder: strings.enterFullName(),
defaultValue: userProfile?.fullName,
positivePress: async (value) => {
db.settings
.setProfile({
fullName: value
})
.then(async () => {
form: {
formRef: createFormRef({
fullName: userProfile?.fullName || ""
}),
items: [
{
name: "fullName",
placeholder: strings.enterFullName(),
defaultValue: userProfile?.fullName,
validators: [
validators.required(strings.nameIsRequired())
]
}
],
onFormSubmit: async (form) => {
try {
await db.settings.setProfile({
fullName: form.getValue("fullName")
});
useUserStore.setState({
profile: db.settings.getProfile()
});
});
return true;
} catch (e) {
form.setError("fullName", e.message);
return false;
}
}
}
});
}}

View File

@@ -98,6 +98,7 @@ export const Trash = ({ navigation, route }: NavigationProps<"Trash">) => {
<List
data={trash}
dataType="trash"
groupType="trash"
renderedInRoute="Trash"
loading={!isFocused}
placeholder={PLACEHOLDER_DATA(db.settings.getTrashCleanupInterval())}

View File

@@ -71,6 +71,7 @@ const routeNames = {
Archive: "Archive",
ManageTags: "ManageTags",
AddReminder: "AddReminder",
RelationsList: "RelationsList",
PayWall: "PayWall",
Wrapped: "Wrapped"
};

View File

@@ -17,9 +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 { NativeModules, Platform } from "react-native";
//@ts-ignore
import { enabled } from "react-native-privacy-snapshot";
import { Platform } from "react-native";
import { MMKV } from "../common/database/mmkv";
import {
SettingStore,
@@ -28,9 +26,9 @@ import {
} from "../stores/use-setting-store";
import { NotesnookModule } from "../utils/notesnook-module";
import { scale, updateSize } from "../utils/size";
import { DatabaseLogger } from "../common/database";
import { useUserStore } from "../stores/use-user-store";
import ScreenGuardModule from "react-native-screenguard";
ScreenGuardModule.initSettings();
function reset() {
const settings = get();
@@ -54,11 +52,10 @@ function resetSettings() {
defaultSnoozeTime: get().defaultSnoozeTime,
defaultFontFamily: get().defaultFontFamily,
defaultFontSize: get().defaultFontSize,
privacyScreen: get().privacyScreen,
corsProxy: get().corsProxy,
showBackupCompleteSheet: get().showBackupCompleteSheet
};
setPrivacyScreen(false);
MMKV.setString("appSettings", JSON.stringify(settings));
set(settings);
init();
@@ -91,7 +88,6 @@ function migrateAppLock() {
biometricsAuthEnabled: true
});
}
DatabaseLogger.debug("App lock Migrated");
}
function migrateSettings(settings: SettingStore["settings"]) {
@@ -101,6 +97,7 @@ function migrateSettings(settings: SettingStore["settings"]) {
settings.privacyScreen = settings.appLockEnabled
? true
: settings.privacyScreen;
setPrivacyScreen(settings.privacyScreen);
MMKV.setString("appSettings", JSON.stringify(settings));
}
}
@@ -126,28 +123,21 @@ function init() {
updateSize();
useSettingStore.getState().setSettings({ ...settings });
migrateAppLock();
setPrivacyScreen(settings);
}
function setPrivacyScreen(settings: SettingStore["settings"]) {
if (settings.privacyScreen) {
function setPrivacyScreen(enabled?: boolean) {
if (enabled) {
if (Platform.OS === "android") {
NotesnookModule.setSecureMode(true);
ScreenGuardModule.registerWithoutEffect();
} else {
enabled(true);
if (NativeModules.ScreenGuard) {
ScreenGuardModule.register({ backgroundColor: "#000000" });
}
ScreenGuardModule.register({ backgroundColor: "#000000" });
}
} else {
if (Platform.OS === "android") {
NotesnookModule.setSecureMode(false);
} else {
enabled(false);
if (NativeModules.ScreenGuard) {
ScreenGuardModule.unregister();
}
}
ScreenGuardModule.unregister();
}
}

View File

@@ -108,6 +108,13 @@ export interface RouteParams extends ParamListBase {
reminder?: Reminder;
reference?: Note;
};
RelationsList: {
item: Item;
referenceType: "notebook" | "tag" | "reminder" | "note";
relationType: "to" | "from";
title: string;
onAdd?: () => void;
};
Intro: GenericRouteParam;
PayWall: {
canGoBack?: boolean;

View File

@@ -58,6 +58,7 @@ export type Settings = {
appLockMode?: "none" | "background" | "launch";
notebooksListMode?: "normal" | "compact";
notesListMode?: "normal" | "compact";
searchListMode?: "normal" | "compact";
devMode?: boolean;
notifNotes?: boolean;
pitchBlack?: boolean;
@@ -172,6 +173,7 @@ export const defaultSettings: SettingStore["settings"] = {
appLockMode: "none",
notebooksListMode: "normal",
notesListMode: "normal",
searchListMode: "normal",
devMode: false,
notifNotes: false,
pitchBlack: false,

View File

@@ -48,7 +48,8 @@ export const SORT = {
dateCreated: "Date created",
title: "Title",
dueDate: "Due date",
relevance: "Relevance"
relevance: "Relevance",
dateDeleted: "Date deleted"
};
export const itemSkus = [

View File

@@ -65,6 +65,14 @@ export async function unlockVault({
paragraph: paragraph,
inputPlaceholder: strings.enterPassword(),
positivePress: async (value) => {
if (!value || !value.trim()) {
ToastManager.error(
new Error(strings.passwordNotEntered()),
undefined,
"local"
);
return;
}
const unlocked = await db.vault.unlock(value);
if (!unlocked) {
ToastManager.show({

View File

@@ -2235,14 +2235,35 @@ PODS:
- ReactCommon/turbomodule/core
- SocketRocket
- Yoga
- react-native-screenguard (1.0.0):
- react-native-screenguard (2.0.0-beta5):
- boost
- DoubleConversion
- fast_float
- fmt
- glog
- hermes-engine
- RCT-Folly
- RCT-Folly/Fabric
- RCTRequired
- RCTTypeSafety
- React-Codegen
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-jsi
- React-NativeModulesApple
- React-RCTFabric
- React-renderercss
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- SDWebImage (~> 5.11.1)
- SDWebImage (~> 5.11)
- SocketRocket
- Yoga
- react-native-share-extension (2.9.5):
- React
- react-native-sodium (1.6.8):
@@ -3097,8 +3118,6 @@ PODS:
- RNNotifee/NotifeeCore (= 7.4.12)
- RNNotifee/NotifeeCore (7.4.12):
- React-Core
- RNPrivacySnapshot (1.0.0):
- React-Core
- RNReanimated (4.2.0):
- boost
- DoubleConversion
@@ -3573,7 +3592,6 @@ DEPENDENCIES:
- RNImageCropPicker (from `../node_modules/react-native-image-crop-picker`)
- RNKeychain (from `../node_modules/react-native-keychain`)
- "RNNotifee (from `../node_modules/@ammarahmed/notifee-react-native`)"
- RNPrivacySnapshot (from `../node_modules/react-native-privacy-snapshot`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
- RNScreens (from `../node_modules/react-native-screens`)
- RNSecureRandom (from `../node_modules/react-native-securerandom`)
@@ -3839,8 +3857,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-keychain"
RNNotifee:
:path: "../node_modules/@ammarahmed/notifee-react-native"
RNPrivacySnapshot:
:path: "../node_modules/react-native-privacy-snapshot"
RNReanimated:
:path: "../node_modules/react-native-reanimated"
RNScreens:
@@ -3942,7 +3958,7 @@ SPEC CHECKSUMS:
react-native-pdf: edc236298f13f1609e42d41e45b8b6ea88ed10f9
react-native-quick-sqlite: 1ed8d3db1e22a8604d006be69f06053382e93bb0
react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616
react-native-screenguard: 9fc3b4ad5b97783fc0832638fae0dce51272c661
react-native-screenguard: 975d4612dce0c348b19b08a48aa23e9b68584d98
react-native-share-extension: fdc6aaab51591a2d445df239c446aaa3a99658ec
react-native-sodium: 066f76e46c9be13e9260521e3fa994937c4cdab4
react-native-theme-switch-animation: 449d6db7a760f55740505e7403ae8061debc9a7e
@@ -3995,7 +4011,6 @@ SPEC CHECKSUMS:
RNImageCropPicker: 5fd4ceaead64d8c53c787e4e559004f97bc76df7
RNKeychain: ffd0513e676445c637410b47249460cbf56bc9cb
RNNotifee: dea82c9ec44684eeeac9da85deb5eeb8ebe5937f
RNPrivacySnapshot: ccad3a548338c2f526bb7b1789af3fb0618b7d1d
RNReanimated: f1868b36f4b2b52a0ed00062cfda69506f75eaee
RNScreens: ffbb0296608eb3560de641a711bbdb663ed1f6b4
RNSecureRandom: b64d263529492a6897e236a22a2c4249aa1b53dc

View File

@@ -1,6 +1,6 @@
// Production iOS build identifiers
IOS_CURRENT_PROJECT_VERSION = 2180
IOS_MARKETING_VERSION = 3.3.20
IOS_CURRENT_PROJECT_VERSION = 2185
IOS_MARKETING_VERSION = 3.3.25
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share

View File

@@ -1,6 +1,6 @@
// Production iOS build identifiers
IOS_CURRENT_PROJECT_VERSION = 2180
IOS_MARKETING_VERSION = 3.3.20
IOS_CURRENT_PROJECT_VERSION = 2185
IOS_MARKETING_VERSION = 3.3.25
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share

View File

@@ -1,6 +1,6 @@
// Staging iOS build identifiers
IOS_CURRENT_PROJECT_VERSION = 2180
IOS_MARKETING_VERSION = 3.3.20
IOS_CURRENT_PROJECT_VERSION = 2185
IOS_MARKETING_VERSION = 3.3.25
IOS_MAIN_BUNDLE_ID = org.streetwriters.notesnook
IOS_WIDGET_BUNDLE_ID = org.streetwriters.notesnook.notewidget
IOS_SHARE_BUNDLE_ID = org.streetwriters.notesnook.share

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/mobile",
"version": "3.3.19",
"version": "3.3.25",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/mobile",
"version": "3.3.19",
"version": "3.3.25",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {
@@ -51,10 +51,10 @@
"@sayem314/react-native-keep-awake": "^1.3.1",
"@streetwriters/kysely": "^0.27.4",
"@streetwriters/showdown": "^3.0.1-alpha.2",
"@tanstack/react-query": "^4.36.1",
"@trpc/client": "^10.45.2",
"@trpc/react-query": "^10.45.2",
"@trpc/server": "^10.45.2",
"@tanstack/react-query": "^4.18.0",
"@trpc/client": "10.45.2",
"@trpc/react-query": "10.45.2",
"@trpc/server": "10.45.2",
"@types/validator": "^13.12.2",
"absolutify": "^0.1.0",
"async-mutex": "0.5.0",
@@ -105,7 +105,6 @@
"react-native-notification-sounds": "0.5.5",
"react-native-orientation-locker": "^1.7.0",
"react-native-pdf": "^7.0.3",
"react-native-privacy-snapshot": "github:standardnotes/react-native-privacy-snapshot",
"react-native-progress": "5.0.0",
"react-native-qrcode-svg": "^6.0.6",
"react-native-quick-sqlite": "^8.2.7",
@@ -113,7 +112,7 @@
"react-native-reanimated-progress-bar": "1.0.1",
"react-native-safe-area-context": "^5.6.1",
"react-native-scoped-storage": "^1.9.5",
"react-native-screenguard": "1.0.0",
"react-native-screenguard": "^2.0.0-beta5",
"react-native-screens": "^4.19.0",
"react-native-securerandom": "^1.0.1",
"react-native-share": "^12.0.3",
@@ -5635,9 +5634,9 @@
}
},
"node_modules/@tanstack/query-core": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.41.0.tgz",
"integrity": "sha512-193R4Jp9hjvlij6LryxrB5Mpbffd2L9PeWh3KlIy/hJV4SkBOfiQZ+jc5qAZLDCrdbkA5FjGj+UoDYw6TcNnyA==",
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.18.0.tgz",
"integrity": "sha512-PP4mG8MD08sq64RZCqMfXMYfaj7+Oulwg7xZ/fJoEOdTZNcPIgaOkHajZvUBsNLbi/0ViMvJB4cFkL2Jg2WPbw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -5645,12 +5644,12 @@
}
},
"node_modules/@tanstack/react-query": {
"version": "4.42.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.42.0.tgz",
"integrity": "sha512-j0tiofkzE3CSrYKmVRaKuwGgvCE+P2OOEDlhmfjeZf5ufcuFHwYwwgw3j08n4WYPVZ+OpsHblcFYezhKA3jDwg==",
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.18.0.tgz",
"integrity": "sha512-s1kdbGMdVcfUIllzsHUqVUdktBT5uuIRgnvrqFNLjl9TSOXEoBSDrhjsGjao0INQZv8cMpQlgOh3YH9YtN6cKw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "4.41.0",
"@tanstack/query-core": "4.18.0",
"use-sync-external-store": "^1.2.0"
},
"funding": {
@@ -5658,8 +5657,8 @@
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-native": "*"
},
"peerDependenciesMeta": {
@@ -15884,15 +15883,6 @@
"prop-types": "*"
}
},
"node_modules/react-native-privacy-snapshot": {
"version": "1.0.0",
"resolved": "git+ssh://git@github.com/standardnotes/react-native-privacy-snapshot.git#653e904c90fc6f2b578da59138f2bfe5d7f942fe",
"license": "MIT",
"peerDependencies": {
"react": "^16.13.1",
"react-native": "^0.62.0"
}
},
"node_modules/react-native-progress": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-native-progress/-/react-native-progress-5.0.0.tgz",
@@ -15993,16 +15983,23 @@
}
},
"node_modules/react-native-screenguard": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/react-native-screenguard/-/react-native-screenguard-1.0.0.tgz",
"integrity": "sha512-fqtoJI8TxszKqrntnHW48EMb4gNVA9VCAg5UG1FcIXEyv2W70kWoMxRRFoWuCRYlBWSAWqiXO+R6Xfk/0um0cw==",
"version": "2.0.0-beta5",
"resolved": "https://registry.npmjs.org/react-native-screenguard/-/react-native-screenguard-2.0.0-beta5.tgz",
"integrity": "sha512-v8Lnv2am+COzEGKvj6J4UbpQsjHixkSqSJ+tgJtDvwck2Dil1vwOhEJBbngXYgj63x4iFlV9gE4gAOWG60avAg==",
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": ">= 16.0.0"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
"expo": ">=36.0.0",
"react": ">=16.8.3",
"react-native": ">=0.59.0"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
}
}
},
"node_modules/react-native-screens": {

View File

@@ -1,6 +1,6 @@
{
"name": "@notesnook/mobile",
"version": "3.3.20",
"version": "3.3.25",
"private": true,
"license": "GPL-3.0-or-later",
"scripts": {
@@ -69,10 +69,10 @@
"@sayem314/react-native-keep-awake": "^1.3.1",
"@streetwriters/kysely": "^0.27.4",
"@streetwriters/showdown": "^3.0.1-alpha.2",
"@tanstack/react-query": "^4.36.1",
"@trpc/client": "^10.45.2",
"@trpc/react-query": "^10.45.2",
"@trpc/server": "^10.45.2",
"@tanstack/react-query": "^4.18.0",
"@trpc/client": "10.45.2",
"@trpc/react-query": "10.45.2",
"@trpc/server": "10.45.2",
"@types/validator": "^13.12.2",
"absolutify": "^0.1.0",
"async-mutex": "0.5.0",
@@ -123,7 +123,6 @@
"react-native-notification-sounds": "0.5.5",
"react-native-orientation-locker": "^1.7.0",
"react-native-pdf": "^7.0.3",
"react-native-privacy-snapshot": "github:standardnotes/react-native-privacy-snapshot",
"react-native-progress": "5.0.0",
"react-native-qrcode-svg": "^6.0.6",
"react-native-quick-sqlite": "^8.2.7",
@@ -131,7 +130,7 @@
"react-native-reanimated-progress-bar": "1.0.1",
"react-native-safe-area-context": "^5.6.1",
"react-native-scoped-storage": "^1.9.5",
"react-native-screenguard": "1.0.0",
"react-native-screenguard": "^2.0.0-beta5",
"react-native-screens": "^4.19.0",
"react-native-securerandom": "^1.0.1",
"react-native-share": "^12.0.3",
@@ -202,4 +201,4 @@
"engines": {
"node": ">=20"
}
}
}

View File

@@ -1,54 +0,0 @@
diff --git a/node_modules/react-native-screenguard/ios/ScreenGuard.mm b/node_modules/react-native-screenguard/ios/ScreenGuard.mm
index 9fa7d33..d2e6d68 100644
--- a/node_modules/react-native-screenguard/ios/ScreenGuard.mm
+++ b/node_modules/react-native-screenguard/ios/ScreenGuard.mm
@@ -11,7 +11,7 @@
@implementation ScreenGuard
RCT_EXPORT_MODULE(ScreenGuard)
-bool hasListeners;
+bool hasListeners_;
UITextField *textField;
UIImageView *imageView;
@@ -22,11 +22,11 @@ @implementation ScreenGuard
}
- (void)startObserving {
- hasListeners = YES;
+ hasListeners_ = YES;
}
- (void)stopObserving {
- hasListeners = NO;
+ hasListeners_ = NO;
}
- (void)secureViewWithBackgroundColor: (NSString *)color {
@@ -335,7 +335,7 @@ - (UIImage *)convertViewToImage:(UIView *)view {
queue:mainQueue
usingBlock:^(NSNotification *notification) {
- if (hasListeners && getScreenShotPath) {
+ if (hasListeners_ && getScreenShotPath) {
UIViewController *presentedViewController = RCTPresentedViewController();
UIImage *image = [self convertViewToImage:presentedViewController.view.superview];
@@ -359,7 +359,7 @@ - (UIImage *)convertViewToImage:(UIView *)view {
result = @{@"path": filePath, @"name": fileName, @"type": @"PNG"};
}
[self emit:SCREENSHOT_EVT body: result];
- } else if (hasListeners) {
+ } else if (hasListeners_) {
[self emit:SCREENSHOT_EVT body: nil];
}
}];
@@ -376,7 +376,7 @@ - (UIImage *)convertViewToImage:(UIView *)view {
queue:mainQueue
usingBlock:^(NSNotification *notification) {
- if (hasListeners) {
+ if (hasListeners_) {
[self emit:SCREEN_RECORDING_EVT body:nil];
}
}];

View File

@@ -11,12 +11,6 @@ config.dependencies["react-native-vector-icons"] = {
}
};
config.dependencies["react-native-screenguard"] = {
platforms: {
android: null
}
};
if (isGithubRelease) {
config.dependencies["react-native-iap"] = {
platforms: {

View File

@@ -1,12 +1,12 @@
{
"name": "@notesnook/web",
"version": "3.3.16",
"version": "3.3.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@notesnook/web",
"version": "3.3.16",
"version": "3.3.23",
"hasInstallScript": true,
"license": "GPL-3.0-or-later",
"dependencies": {

View File

@@ -1,7 +1,7 @@
{
"name": "@notesnook/web",
"description": "Your private note taking space",
"version": "3.3.16",
"version": "3.3.23",
"private": true,
"main": "./src/app.js",
"homepage": "https://notesnook.com/",

View File

@@ -215,7 +215,10 @@ function DesktopAppContents() {
zIndex: 3
}}
>
<NavigationMenu onExpand={() => navPane.current?.reset(0)} />
<NavigationMenu
onExpand={() => navPane.current?.reset(0)}
canExpand={!isTablet}
/>
</Pane>
)}
{isFocusMode ? null : (

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -231,15 +231,40 @@ export async function restoreBackupFile(backupFile: File) {
}
await db.initCollections();
} else {
const { createUnzipIterator } = await import(
"../utils/streams/unzip-stream"
);
let skipAttachments = false;
if (!useUserStore.getState().isLoggedIn) {
let hasAttachments = false;
for await (const entry of createUnzipIterator(backupFile)) {
if (
entry.name.startsWith("attachments/") &&
entry.name !== "attachments/.attachments_key"
) {
hasAttachments = true;
break;
}
}
if (hasAttachments) {
const result = await ConfirmDialog.show({
title: strings.loginToRestoreAttachments(),
message: strings.loginToRestoreAttachmentsDesc(),
positiveButtonText: strings.yes(),
negativeButtonText: strings.no()
});
if (!result) return;
skipAttachments = true;
}
}
const error = await TaskManager.startTask<Error | void>({
title: strings.restoringBackup(),
subtitle: strings.restoringBackupDesc(),
type: "modal",
action: async (report) => {
const { createUnzipIterator } = await import(
"../utils/streams/unzip-stream"
);
let cachedPassword: string | undefined = undefined;
let cachedKey: string | undefined = undefined;
// const { read, totalFiles } = await Reader(backupFile);
@@ -254,13 +279,13 @@ export async function restoreBackupFile(backupFile: File) {
isValid = true;
continue;
}
if (entry.name === "attachments/.attachments_key")
if (!skipAttachments && entry.name === "attachments/.attachments_key")
attachmentsKey = JSON.parse(await entry.text()) as
| SerializedKey
| Cipher<"base64">;
else if (entry.name.startsWith("attachments/"))
else if (!skipAttachments && entry.name.startsWith("attachments/"))
attachments.push(entry);
else entries.push(entry);
else if (!entry.name.startsWith("attachments/")) entries.push(entry);
}
if (!isValid)
console.warn(
@@ -339,6 +364,8 @@ export async function restoreBackupFile(backupFile: File) {
if (error) {
console.error(error);
showToast("error", `${strings.restoreFailed()}: ${error.message}`);
} else {
showToast("success", strings.backupRestored());
}
}
}

View File

@@ -20,7 +20,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { useMemo } from "react";
import { Box, Button, Flex, Image, Link, Text } from "@theme-ui/components";
import { getRandom, usePromise } from "@notesnook/common";
import Grberk from "../../assets/testimonials/grberk.jpeg";
import Holenstein from "../../assets/testimonials/holenstein.jpg";
import Jason from "../../assets/testimonials/jason.jpg";
import Cameron from "../../assets/testimonials/cameron.jpg";
@@ -29,13 +28,6 @@ import { SettingsDialog } from "../../dialogs/settings";
import { strings } from "@notesnook/intl";
const testimonials = [
{
username: "grberk",
image: Grberk,
name: "Glenn Berkshier",
link: "https://twitter.com/grberk/status/1438955961490751489",
text: "Are you looking for an alternative to @evernote, or just looking for a more secure note taking platform? Take a look at @notesnook and see if it will fit your needs."
},
{
username: "HolensteinDan",
image: Holenstein,

View File

@@ -16,6 +16,7 @@ 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 { useDatePicker } from "@rehookify/datepicker";
import { Box, Button, Flex, Text } from "@theme-ui/components";
import { useState } from "react";
@@ -31,6 +32,7 @@ type DayPickerProps = {
export function DayPicker(props: DayPickerProps) {
const { selected, sx, maxDate, minDate, onSelect } = props;
const [selectedDates, onDatesChange] = useState<Date[]>([selected]);
const {
data: { weekDays, months, years, calendars },
propGetters: { dayButton, addOffset, subtractOffset, setOffset }
@@ -44,7 +46,21 @@ export function DayPicker(props: DayPickerProps) {
maxDate,
minDate
},
years: { numberOfYears: 99 },
years: {
/**
* When minDate and maxDate are provided, we switch to "fluid" mode and calculate the number of years in a way that all valid years are visible.
*
* When not provided, use default mode and numberOfYears.
*/
numberOfYears:
minDate && maxDate
? Math.max(
Math.abs(maxDate.getFullYear() - selected.getFullYear()) + 1,
Math.abs(selected.getFullYear() - minDate.getFullYear()) + 1
) * 2
: 99,
mode: minDate && maxDate ? "fluid" : "decade"
},
calendar: {
startDay: 0
}
@@ -52,6 +68,24 @@ export function DayPicker(props: DayPickerProps) {
const { month, year, days } = calendars[0];
const currentMonthDate = months.find((m) => m.month === month)?.$date;
const isNextMonthBeyondMax =
maxDate && currentMonthDate
? new Date(
currentMonthDate.getFullYear(),
currentMonthDate.getMonth() + 1,
1
) > stripTime(maxDate)
: false;
const isPrevMonthBeforeMin =
minDate && currentMonthDate
? new Date(
currentMonthDate.getFullYear(),
currentMonthDate.getMonth(),
1
) <= stripTime(minDate)
: false;
return (
<Flex
sx={{
@@ -66,7 +100,10 @@ export function DayPicker(props: DayPickerProps) {
<Button
variant="icon"
sx={{ p: 0 }}
{...subtractOffset({ months: 1 })}
{...subtractOffset(
{ months: 1 },
{ disabled: isPrevMonthBeforeMin }
)}
>
<ChevronLeft />
</Button>
@@ -83,9 +120,12 @@ export function DayPicker(props: DayPickerProps) {
value={month}
onChange={(e) => {
const selectedOption = e.target.selectedOptions[0];
setOffset(new Date(selectedOption.dataset.date || ""))?.onClick?.(
e
const d = clampDate(
new Date(selectedOption.dataset.date || ""),
minDate,
maxDate
);
setOffset(d)?.onClick?.(e as any);
}}
>
{months.map((month) => (
@@ -93,6 +133,7 @@ export function DayPicker(props: DayPickerProps) {
key={month.month + year}
value={month.month}
data-date={month.$date.toDateString()}
disabled={month.disabled}
>
{month.month}
</option>
@@ -111,9 +152,12 @@ export function DayPicker(props: DayPickerProps) {
value={year}
onChange={(e) => {
const selectedOption = e.target.selectedOptions[0];
setOffset(new Date(selectedOption.dataset.date || ""))?.onClick?.(
e
const d = clampDate(
new Date(selectedOption.dataset.date || ""),
minDate,
maxDate
);
setOffset(d)?.onClick?.(e as any);
}}
>
{years.map((year) => (
@@ -121,13 +165,23 @@ export function DayPicker(props: DayPickerProps) {
key={year.year}
value={year.year}
data-date={year.$date.toDateString()}
disabled={year.disabled}
>
{year.year}
</option>
))}
</select>
</Flex>
<Button variant="icon" sx={{ p: 0 }} {...addOffset({ months: 1 })}>
<Button
variant="icon"
sx={{ p: 0 }}
{...addOffset(
{ months: 1 },
{
disabled: isNextMonthBeyondMax
}
)}
>
<ChevronRight />
</Button>
</Flex>
@@ -193,3 +247,24 @@ export function DayPicker(props: DayPickerProps) {
</Flex>
);
}
function clampDate(d: Date, minDate?: Date, maxDate?: Date) {
/**
* Strip time for accurate comparison
*/
const normalizedMaxDate = maxDate ? stripTime(maxDate) : null;
const normalizedMinDate = minDate ? stripTime(minDate) : null;
if (normalizedMaxDate && d > normalizedMaxDate) {
return normalizedMaxDate;
}
if (normalizedMinDate && d < normalizedMinDate) {
return normalizedMinDate;
}
return d;
}
function stripTime(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}

View File

@@ -27,7 +27,6 @@ import {
Lock,
NewTab,
Note,
NoteAdd,
NoteRemove,
Pin,
Plus,
@@ -98,7 +97,6 @@ type ToolButton = {
export function EditorActionBar() {
const { isMaximized, isFullscreen, hasNativeWindowControls } =
useWindowControls();
const isFocusMode = useAppStore((store) => store.isFocusMode);
const activeTab = useEditorStore((store) => store.getActiveTab());
const activeSession = useEditorStore((store) =>
activeTab ? store.getSession(activeTab.sessionId) : undefined
@@ -187,8 +185,7 @@ export function EditorActionBar() {
activeSession &&
activeSession.type !== "new" &&
activeSession.type !== "locked" &&
activeSession.type !== "conflicted" &&
!isFocusMode,
activeSession.type !== "conflicted",
onClick: () => useEditorStore.getState().toggleProperties(),
toggled: arePropertiesVisible
},

View File

@@ -50,7 +50,8 @@ import { FlexScrollContainer } from "../scroll-container";
import Tiptap, { OnChangeHandler } from "./tiptap";
import Header from "./header";
import { Attachment } from "../icons";
import { attachFiles, AttachmentProgress, insertAttachments } from "./picker";
import { AttachmentProgress, insertAttachments } from "./picker";
import { AttachFilesDialog } from "../../dialogs/attach-files-dialog";
import { useEditorManager } from "./manager";
import {
saveAttachment,
@@ -617,13 +618,11 @@ export function Editor(props: EditorProps) {
}
const mime = type === "file" ? "*/*" : "image/*";
const attachments = await insertAttachments(mime);
const editor = useEditorManager.getState().getEditor(id)?.editor;
if (!attachments) return;
for (const attachment of attachments) {
editor?.attachFile(attachment);
}
await insertAttachments(mime, (attachments) => {
const editor = useEditorManager.getState().getEditor(id)?.editor;
if (!editor) return;
attachments.forEach((a) => editor?.attachFile(a));
});
}}
onGetAttachmentData={async (attachment) => {
logger.debug("Getting attachment data", {
@@ -646,10 +645,14 @@ export function Editor(props: EditorProps) {
return result;
}}
onAttachFiles={async (files) => {
const editor = useEditorManager.getState().getEditor(id)?.editor;
const result = await attachFiles(files);
if (!result) return;
result.forEach((attachment) => editor?.attachFile(attachment));
await AttachFilesDialog.show({
files,
onDone: (attachments) => {
const editor = useEditorManager.getState().getEditor(id)?.editor;
if (!editor) return;
attachments.forEach((a) => editor?.attachFile(a));
}
});
}}
onInsertInternalLink={async (attributes) => {
const link = await NoteLinkingDialog.show({ attributes });
@@ -799,17 +802,20 @@ function DropZone(props: DropZoneProps) {
}}
onDrop={async (e) => {
try {
const { activeEditorId, getEditor } = useEditorManager.getState();
const editor = getEditor(activeEditorId || "")?.editor;
if (!e.dataTransfer.files?.length || !editor) return;
const { activeEditorId } = useEditorManager.getState();
if (!e.dataTransfer.files?.length || !activeEditorId) return;
e.preventDefault();
const attachments = await attachFiles(
Array.from(e.dataTransfer.files)
);
for (const attachment of attachments || []) {
editor.attachFile(attachment);
}
await AttachFilesDialog.show({
files: Array.from(e.dataTransfer.files),
onDone: (attachments) => {
const editor = useEditorManager
.getState()
.getEditor(activeEditorId)?.editor;
if (!editor) return;
attachments.forEach((a) => editor?.attachFile(a));
}
});
} catch (e) {
logger.error(e as Error, "Failed to attach file from drag and drop");
showToast("error", strings.failedToAttachFile());

View File

@@ -18,86 +18,35 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { SerializedKey } from "@notesnook/crypto";
import { AppEventManager, AppEvents } from "../../common/app-events";
import { db } from "../../common/db";
import { TaskManager } from "../../common/task-manager";
import { showToast } from "../../utils/toast";
import { showFilePicker } from "../../utils/file-picker";
import { Attachment } from "@notesnook/editor";
import { ImagePickerDialog } from "../../dialogs/image-picker-dialog";
import { strings } from "@notesnook/intl";
import {
getUploadedFileSize,
hashStream,
writeEncryptedFile
} from "../../interfaces/fs";
import Config from "../../utils/config";
import { compressImage, FileWithURI } from "../../utils/image-compressor";
import { ImageCompressionOptions } from "../../stores/setting-store";
import { checkFeature } from "../../common";
import { AttachFilesDialog } from "../../dialogs/attach-files-dialog";
import { strings } from "@notesnook/intl";
export async function insertAttachments(type = "*/*") {
export async function insertAttachments(
type: string,
onDone: (attachments: Attachment[]) => void
): Promise<void> {
const files = await showFilePicker({
acceptedFileTypes: type || "*/*",
multiple: true
});
if (!files) return;
return await attachFiles(files, type === "*/*");
}
if (!files || files.length === 0) return;
export async function attachFiles(
files: File[],
skipSpecialImageHandling = false
) {
let images = files.filter((f) => f.type.startsWith("image/"));
const imageCompressionConfig = Config.get<ImageCompressionOptions>(
"imageCompression",
ImageCompressionOptions.ASK_EVERY_TIME
);
switch (imageCompressionConfig) {
case ImageCompressionOptions.ENABLE: {
const compressedImages: FileWithURI[] = [];
for (const image of images) {
const compressed = await compressImage(image, {
maxWidth: (naturalWidth) => Math.min(1920, naturalWidth * 0.7),
width: (naturalWidth) => naturalWidth,
height: (_, naturalHeight) => naturalHeight,
resize: "contain",
quality: 0.7
});
compressedImages.push(
new FileWithURI([compressed], image.name, {
lastModified: image.lastModified,
type: image.type
})
);
}
images = compressedImages;
break;
}
case ImageCompressionOptions.DISABLE:
break;
default:
images =
images.length > 0
? (await ImagePickerDialog.show({
images
})) || []
: [];
}
const documents = files.filter((f) => !f.type.startsWith("image/"));
const attachments: Attachment[] = [];
for (const file of [...images, ...documents]) {
const attachment =
!skipSpecialImageHandling && file.type.startsWith("image/")
? await pickImage(file)
: await pickFile(file);
if (!attachment) continue;
attachments.push(attachment);
}
return attachments;
await AttachFilesDialog.show({
files,
skipSpecialImageHandling: type === "*/*",
onDone
});
}
export async function reuploadAttachment(
@@ -109,9 +58,16 @@ export async function reuploadAttachment(
});
if (!selectedFile) return;
if (
!(await checkFeature("fileSize", {
value: selectedFile.size
}))
) {
return;
}
const options: AddAttachmentOptions = {
expectedFileHash,
showProgress: false,
forceWrite: true
};
@@ -124,6 +80,72 @@ export async function reuploadAttachment(
}
}
type AttachFilesMessage =
| { type: "compressing"; index: number }
| { type: "encrypting"; index: number }
| {
type: "done";
index: number;
attachment: Attachment | undefined;
}
| { type: "error"; index: number; error: string };
export async function* attachFiles(
files: File[],
shouldCompress: boolean[],
skipSpecialImageHandling = false
): AsyncGenerator<AttachFilesMessage> {
for (let i = 0; i < files.length; i++) {
let file = files[i];
const shouldCompressFile = shouldCompress[i];
if (shouldCompressFile) {
yield { type: "compressing", index: i };
try {
const compressed = await compressImage(file, {
maxWidth: (naturalWidth) => Math.min(1920, naturalWidth * 0.7),
width: (naturalWidth) => naturalWidth,
height: (_, naturalHeight) => naturalHeight,
resize: "contain",
quality: 0.7
});
file = new FileWithURI([compressed], file.name, {
lastModified: file.lastModified,
type: file.type
});
} catch (e) {
yield {
type: "error",
index: i,
error: (e as Error).message || strings.compressionFailed()
};
continue;
}
}
yield { type: "encrypting", index: i };
try {
const allowed = await checkFeature("fileSize", {
value: file.size,
type: "toast"
});
if (!allowed) {
throw new Error(strings.fileSizeLimitExceededPleaseUpgrade());
}
const attachment =
!skipSpecialImageHandling && file.type.startsWith("image/")
? await pickImage(file)
: await pickFile(file);
yield { type: "done", index: i, attachment: attachment || undefined };
} catch (e) {
yield { type: "error", index: i, error: (e as Error).message };
}
}
}
/**
* @param {File} file
* @returns
@@ -133,8 +155,6 @@ async function pickFile(
options?: AddAttachmentOptions
): Promise<Attachment | undefined> {
try {
if (!(await checkFeature("fileSize", { value: file.size }))) return;
const hash = await addAttachment(file, options);
return {
type: "file",
@@ -158,8 +178,6 @@ async function pickImage(
options?: AddAttachmentOptions
): Promise<Attachment | undefined> {
try {
if (!(await checkFeature("fileSize", { value: file.size }))) return;
const hash = await addAttachment(file, options);
const dimensions = await getImageDimensions(file);
return {
@@ -171,6 +189,7 @@ async function pickImage(
...dimensions
};
} catch (e) {
console.error(e);
showToast("error", (e as Error).message);
}
}
@@ -190,7 +209,6 @@ export type AttachmentProgress = {
type AddAttachmentOptions = {
expectedFileHash?: string;
showProgress?: boolean;
forceWrite?: boolean;
};
@@ -198,80 +216,46 @@ async function addAttachment(
file: File,
options: AddAttachmentOptions = {}
): Promise<string> {
const { expectedFileHash, showProgress = true } = options;
const { expectedFileHash } = options;
let forceWrite = options.forceWrite;
const action = async () => {
const reader = file.stream().getReader();
const { hash, type: hashType } = await hashStream(reader);
reader.releaseLock();
const reader = file.stream().getReader();
const { hash, type: hashType } = await hashStream(reader);
reader.releaseLock();
if (expectedFileHash && hash !== expectedFileHash)
throw new Error(
`Please select the same file for reuploading. Expected hash ${expectedFileHash} but got ${hash}.`
);
if (expectedFileHash && hash !== expectedFileHash)
throw new Error(
`Please select the same file for reuploading. Expected hash ${expectedFileHash} but got ${hash}.`
);
const exists = await db.attachments.attachment(hash);
if (!forceWrite && exists) {
forceWrite = (await getUploadedFileSize(hash)) === 0;
const exists = await db.attachments.attachment(hash);
if (!forceWrite && exists) {
forceWrite = (await getUploadedFileSize(hash)) === 0;
}
if (forceWrite || !exists) {
if (forceWrite && exists) {
if (!(await db.fs().deleteFile(hash, false)))
throw new Error("Failed to delete attachment from server.");
await db.attachments.reset(exists.id);
}
if (forceWrite || !exists) {
if (forceWrite && exists) {
if (!(await db.fs().deleteFile(hash, false)))
throw new Error("Failed to delete attachment from server.");
await db.attachments.reset(exists.id);
}
const key: SerializedKey = await getEncryptionKey();
const key: SerializedKey = await getEncryptionKey();
const output = await writeEncryptedFile(file, key, hash);
if (!output) throw new Error("Could not encrypt file.");
const output = await writeEncryptedFile(file, key, hash);
if (!output) throw new Error("Could not encrypt file.");
await db.attachments.add({
...output,
hash,
hashType,
filename: exists?.filename || file.name,
mimeType: exists?.type || file.type,
key
});
}
await db.attachments.add({
...output,
hash,
hashType,
filename: exists?.filename || file.name,
mimeType: exists?.type || file.type,
key
});
}
return hash;
};
const result = showProgress
? await withProgress(file, action)
: await action();
if (result instanceof Error) throw result;
return result;
}
function withProgress<T>(
file: File,
action: () => Promise<T>
): Promise<T | Error> {
return TaskManager.startTask({
type: "modal",
title: strings.encryptingAttachment(),
subtitle: strings.encryptingAttachmentDesc(),
action: (report) => {
const event = AppEventManager.subscribe(
AppEvents.UPDATE_ATTACHMENT_PROGRESS,
({ type, total, loaded }: AttachmentProgress) => {
if (type !== "encrypt") return;
report({
current: Math.round((loaded / total) * 100),
total: 100,
text: file.name
});
}
);
return action().finally(() => event.unsubscribe());
}
});
return hash;
}
function getImageDimensions(file: File) {

View File

@@ -58,6 +58,7 @@ import { useStore as useThemeStore } from "../../stores/theme-store";
import { writeToClipboard } from "../../utils/clipboard";
import { useEditorStore } from "../../stores/editor-store";
import { DayFormat, parseInternalLink } from "@notesnook/core";
import { desktop } from "../../common/desktop-bridge";
import Skeleton from "react-loading-skeleton";
import useMobile from "../../hooks/use-mobile";
import useTablet from "../../hooks/use-tablet";
@@ -68,6 +69,7 @@ import { showFeatureNotAllowedToast } from "../../common/toasts";
import { UpgradeDialog } from "../../dialogs/buy-dialog/upgrade-dialog";
import { ConfirmDialog } from "../../dialogs/confirm";
import { strings } from "@notesnook/intl";
import { showToast } from "../../utils/toast";
export type OnChangeHandler = (
content: () => string,
@@ -421,13 +423,32 @@ function TipTap(props: TipTapProps) {
previewAttachment: onPreviewAttachment,
createInternalLink: onInsertInternalLink,
getAttachmentData: onGetAttachmentData,
openLink: (url, openInNewTab) => {
openLink: async (url, openInNewTab) => {
const link = parseInternalLink(url);
if (link && link.type === "note") {
useEditorStore.getState().openSession(link.id, {
activeBlockId: link.params?.blockId || undefined,
openInNewTab: openInNewTab
});
} else if (url.startsWith("file:")) {
if (!IS_DESKTOP_APP) {
showToast("error", strings.cantOpenFileLinksInBrowsers());
return;
}
const path = new URL(url).pathname;
const ok = await ConfirmDialog.show({
title: strings.openingLocalFile(),
message: strings.openingLocalFileDesc(path),
positiveButtonText: strings.open(),
negativeButtonText: strings.cancel()
});
if (!ok) return;
await desktop?.integration.openPath.query({
type: "path",
link: decodeURIComponent(path)
});
} else window.open(url, "_blank");
}
};

View File

@@ -45,6 +45,7 @@ import {
} from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useStore as useSearchStore } from "../../stores/search-store";
import type { Context } from "../list-container/types";
const groupByToTitleMap = {
none: "None",
@@ -61,6 +62,7 @@ type GroupingMenuOptions = {
groupingKey: GroupingKey;
refresh: () => void;
isSearching?: boolean;
context?: Context;
};
const groupByMenu: (options: GroupingMenuOptions) => MenuItem | null = (
@@ -192,6 +194,39 @@ export function showSortMenu(groupingKey: GroupingKey, refresh: () => void) {
);
}
function getGroupOptions(
context: Context | undefined,
isSearching: boolean | undefined,
groupingKey: GroupingKey
): GroupOptions {
return isSearching
? db.settings.getGroupOptions("search")
: context?.type === "notebook" ||
context?.type === "tag" ||
context?.type === "color"
? db.settings.getGroupOptionsById(context.id, context.type)
: db.settings.getGroupOptions(groupingKey);
}
async function setGroupOptions(
options: GroupingMenuOptions,
groupOptions: GroupOptions
) {
if (
options.context?.type === "notebook" ||
options.context?.type === "tag" ||
options.context?.type === "color"
) {
await db.settings.setGroupOptionsById(
options.context.id,
options.context.type,
groupOptions
);
} else {
await db.settings.setGroupOptions(options.groupingKey, groupOptions);
}
}
async function changeGroupOptions(
options: GroupingMenuOptions,
item: Omit<MenuButtonItem, "type">
@@ -207,7 +242,9 @@ async function changeGroupOptions(
? "dateModified"
: groupOptions.sortBy;
}
await db.settings.setGroupOptions(options.groupingKey, groupOptions);
await setGroupOptions(options, groupOptions);
if (options.groupingKey === "search")
useSearchStore.setState({ sortOptions: groupOptions });
options.refresh();
@@ -235,6 +272,7 @@ type GroupHeaderProps = {
onSelectGroup: () => void;
isFocused: boolean;
isSearching?: boolean;
context?: Context;
};
function GroupHeader(props: GroupHeaderProps) {
const {
@@ -246,10 +284,12 @@ function GroupHeader(props: GroupHeaderProps) {
refresh,
onSelectGroup,
isFocused,
isSearching
isSearching,
context
} = props;
const [groupOptions, setGroupOptions] = useState(
db.settings.getGroupOptions(isSearching ? "search" : groupingKey)
getGroupOptions(context, isSearching, groupingKey)
);
const groupHeaderRef = useRef<HTMLDivElement>(null);
const { openMenu, target } = useMenuTrigger();
@@ -359,8 +399,10 @@ function GroupHeader(props: GroupHeaderProps) {
groupByToTitleMap[groupOptions.groupBy || "default"]
}`}
onClick={() => {
const groupOptions = db.settings.getGroupOptions(
isSearching ? "search" : groupingKey
const groupOptions = getGroupOptions(
context,
isSearching,
groupingKey
);
setGroupOptions(groupOptions);
@@ -368,7 +410,8 @@ function GroupHeader(props: GroupHeaderProps) {
groupingKey: isSearching ? "search" : groupingKey,
groupOptions,
refresh,
isSearching
isSearching,
context
};
const groupBy = groupByMenu({
...menuOptions,

View File

@@ -229,7 +229,8 @@ import {
mdiArrowUp,
mdiInbox,
mdiConsoleLine,
mdiDeleteSweepOutline
mdiDeleteSweepOutline,
mdiCloseCircle
} from "@mdi/js";
import { useTheme } from "@emotion/react";
import { Theme } from "@notesnook/theme";
@@ -586,3 +587,4 @@ export const HamburgerMenu = createIcon(mdiMenu);
export const ArrowUp = createIcon(mdiArrowUp);
export const Inbox = createIcon(mdiInbox);
export const ClearTrash = createIcon(mdiDeleteSweepOutline);
export const CloseCircle = createIcon(mdiCloseCircle);

View File

@@ -370,6 +370,7 @@ function ItemRenderer({
title={resolvedItem.group.title}
isFocused={index === focusedGroupIndex}
index={index}
context={itemContext}
onSelectGroup={async () => {
if (!items.groups) return;

View File

@@ -228,7 +228,13 @@ const tabs: NavigationTabItem[] = [
}
] as const;
function NavigationMenu({ onExpand }: { onExpand?: () => void }) {
function NavigationMenu({
onExpand,
canExpand
}: {
onExpand?: () => void;
canExpand: boolean;
}) {
const isFocusMode = useAppStore((store) => store.isFocusMode);
const navigationTab = useAppStore((store) => store.navigationTab);
const setNavigationTab = useAppStore((store) => store.setNavigationTab);
@@ -352,6 +358,7 @@ function NavigationMenu({ onExpand }: { onExpand?: () => void }) {
sx={{ p: 1, bg: "transparent" }}
onClick={onExpand}
title={strings.expandSidebar()}
disabled={!canExpand}
>
<ExpandSidebar size={13} color="icon" />
</Button>

View File

@@ -104,6 +104,7 @@ import ListItem from "../list-item";
import { PublishDialog } from "../publish-view";
import TimeAgo from "../time-ago";
import { NoteExpiryDateDialog } from "../../dialogs/note-expiry-date-dialog";
import { withFeatureCheck } from "../../common";
type NoteProps = NoteResolvedData & {
item: NoteType;
@@ -665,11 +666,11 @@ export const noteMenuItems: (
title: strings.setExpiry(),
icon: Destruct.path,
premium: !features.expiringNotes.isAllowed,
onClick: async () => {
onClick: withFeatureCheck(features.expiringNotes, async () => {
await NoteExpiryDateDialog.show({
noteId: note.id
});
}
})
},
{
type: "button",

View File

@@ -41,7 +41,6 @@ import {
DefaultEditorSession
} from "../../stores/editor-store";
import { db } from "../../common/db";
import { useStore as useAppStore } from "../../stores/app-store";
import { useStore as useAttachmentStore } from "../../stores/attachment-store";
import { store as noteStore } from "../../stores/note-store";
import Toggle from "./toggle";
@@ -109,7 +108,6 @@ type EditorPropertiesProps = {
};
function EditorProperties(props: EditorPropertiesProps) {
const toggleProperties = useEditorStore((store) => store.toggleProperties);
const isFocusMode = useAppStore((store) => store.isFocusMode);
const dateFormat = useSettingStore((store) => store.dateFormat);
const timeFormat = useSettingStore((store) => store.timeFormat);
const metadataItems = [
@@ -140,7 +138,8 @@ function EditorProperties(props: EditorPropertiesProps) {
"diff"
])
);
if (isFocusMode || !session) return null;
if (!session) return null;
return (
<Flex
sx={{

View File

@@ -60,6 +60,7 @@ function PublishView(props: PublishViewProps) {
const publishNote = useStore((store) => store.publish);
const unpublishNote = useStore((store) => store.unpublish);
const [monograph, setMonograph] = useState(props.monograph);
const [copied, setCopied] = useState(false);
const monographAnalytics = useIsFeatureAvailable("monographAnalytics");
const analytics = usePromise(async () => {
if (!monographAnalytics?.isAllowed || !monograph) return { totalViews: 0 };
@@ -110,12 +111,15 @@ function PublishView(props: PublishViewProps) {
<Button
variant="secondary"
className="copyPublishLink"
sx={{ flexShrink: 0, m: 0 }}
sx={{ flexShrink: 0, m: 0, color: copied ? "accent" : "initial" }}
onClick={() => {
writeText(`${hosts.MONOGRAPH_HOST}/${monograph?.id}`);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
disabled={copied}
>
{strings.copy()}
{copied ? strings.copied() : strings.copy()}
</Button>
</Flex>
) : null}

View File

@@ -44,7 +44,7 @@ export const AddNotebookDialog = DialogManager.register(
const onSubmit = useCallback(async () => {
if (!title.current.trim())
return showToast("error", strings.allFieldsRequired());
return showToast("error", strings.titleIsRequired());
const id = await db.notebooks.add({
id: props.notebook?.id,

View File

@@ -48,6 +48,8 @@ import Skeleton from "react-loading-skeleton";
dayjs.extend(customParseFormat);
const MAX_DATE = dayjs().add(99, "year").endOf("year").toDate();
export type AddReminderDialogProps = BaseDialogProps<boolean> & {
reminder?: Reminder;
note?: Note;
@@ -200,6 +202,14 @@ export const AddReminderDialog = DialogManager.register(
return;
}
if (mode !== Modes.REPEAT && date.isAfter(MAX_DATE)) {
showToast(
"error",
strings.maximumReminderDate(getFormattedDate(MAX_DATE, "date"))
);
return;
}
const id = await db.reminders.add({
id: reminder?.id,
recurringMode,
@@ -431,7 +441,7 @@ export const AddReminderDialog = DialogManager.register(
}}
selected={dayjs(date).toDate()}
minDate={new Date()}
maxDate={new Date(new Date().getFullYear() + 99, 11, 31)}
maxDate={MAX_DATE}
onSelect={(day) => {
if (!day) return;
const date = getFormattedDate(day, "date");

View File

@@ -0,0 +1,373 @@
/*
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 { useEffect, useMemo, useRef, useState } from "react";
import Dialog from "../components/dialog";
import { ScrollContainer } from "@notesnook/ui";
import { Box, Flex, Image, Switch, Text } from "@theme-ui/components";
import { formatBytes } from "@notesnook/common";
import { BaseDialogProps, DialogManager } from "../common/dialog-manager";
import { strings } from "@notesnook/intl";
import { checkFeature } from "../common";
import { AppEventManager, AppEvents } from "../common/app-events";
import { attachFiles, AttachmentProgress } from "../components/editor/picker";
import Config from "../utils/config";
import { ImageCompressionOptions } from "../stores/setting-store";
import { Attachment } from "@notesnook/editor";
import {
CheckCircle,
Loading,
File as FileIcon,
CloseCircle
} from "../components/icons";
type FileStatus = "pending" | "compressing" | "encrypting" | "done" | "error";
type FileState = {
file: File;
status: FileStatus;
progress: number;
error?: string;
compress: boolean;
};
type AttachFilesDialogProps = BaseDialogProps<false> & {
files: File[];
skipSpecialImageHandling?: boolean;
onDone: (attachments: Attachment[]) => void;
};
export const AttachFilesDialog = DialogManager.register(
function AttachFilesDialog({
files,
skipSpecialImageHandling,
onDone,
onClose
}: AttachFilesDialogProps) {
const hasImages = files.some((f) => f.type.startsWith("image/"));
const imageCompressionConfig = Config.get<ImageCompressionOptions>(
"imageCompression",
ImageCompressionOptions.ASK_EVERY_TIME
);
const [fileStates, setFileStates] = useState<FileState[]>(() =>
files.map((file) => ({
file,
status: "pending",
progress: 0,
compress: file.type.startsWith("image/")
? imageCompressionConfig !== ImageCompressionOptions.DISABLE
: false
}))
);
const [showCompressionPrompt, setShowCompressionPrompt] = useState(
hasImages &&
imageCompressionConfig === ImageCompressionOptions.ASK_EVERY_TIME
);
const processingRef = useRef(false);
useEffect(() => {
const event = AppEventManager.subscribe(
AppEvents.UPDATE_ATTACHMENT_PROGRESS,
({ type, total, loaded }: AttachmentProgress) => {
if (type !== "encrypt") return;
setFileStates((prev) =>
prev.map((s) => {
/**
* only one file is encrypted at a time, so we can just update progress of the state with "encrypting" status
*/
if (s.status !== "encrypting") return s;
return {
...s,
progress: Math.round((loaded / total) * 100)
};
})
);
}
);
return () => {
event.unsubscribe();
};
}, []);
useEffect(() => {
if (showCompressionPrompt || processingRef.current) return;
processingRef.current = true;
const shouldCompress: boolean[] = fileStates.map((s) => !!s.compress);
(async () => {
const attachments: Attachment[] = [];
let hasError = false;
for await (const message of attachFiles(
files,
shouldCompress,
skipSpecialImageHandling
)) {
const { index } = message;
switch (message.type) {
case "compressing":
setFileStates((prev) =>
prev.map((s, i) =>
i === index
? { ...s, status: "compressing" as FileStatus }
: s
)
);
break;
case "encrypting":
setFileStates((prev) =>
prev.map((s, i) =>
i === index
? { ...s, status: "encrypting" as FileStatus, progress: 0 }
: s
)
);
break;
case "done":
if (message.attachment) attachments.push(message.attachment);
setFileStates((prev) =>
prev.map((s, i) =>
i === index
? {
...s,
status: "done" as FileStatus
}
: s
)
);
break;
case "error":
hasError = true;
setFileStates((prev) =>
prev.map((s, i) =>
i === index
? {
...s,
status: "error" as FileStatus,
error: message.error
}
: s
)
);
break;
}
}
onDone(attachments);
if (files.length === 1 && !hasError) onClose(false);
})();
}, [showCompressionPrompt]);
return (
<Dialog
isOpen={true}
title={
showCompressionPrompt
? strings.imageCompression()
: strings.attachingFiles()
}
description={
showCompressionPrompt ? strings.imageCompressionDesc() : ""
}
onClose={() => onClose(false)}
width={500}
positiveButton={
showCompressionPrompt
? {
text: strings.done(),
onClick: () => setShowCompressionPrompt(false)
}
: undefined
}
negativeButton={{
text: strings.close(),
onClick: () => onClose(false)
}}
>
<ScrollContainer
style={{
maxHeight: 350,
display: "flex",
flexDirection: "column",
position: "relative"
}}
>
{fileStates.map((state, index) => (
<FileRow
key={`${state.file.name}-${index}`}
state={state}
showDivider={fileStates.length > 1}
showCompressionToggle={showCompressionPrompt}
onToggleCompress={async () => {
if (
!(await checkFeature("fullQualityImages", { type: "toast" }))
) {
return;
}
setFileStates((prev) =>
prev.map((s, idx) =>
idx === index ? { ...s, compress: !s.compress } : s
)
);
}}
/>
))}
</ScrollContainer>
</Dialog>
);
}
);
function FileRow({
state,
showDivider,
showCompressionToggle,
onToggleCompress
}: {
state: FileState;
showDivider?: boolean;
showCompressionToggle?: boolean;
onToggleCompress?: () => void;
}) {
const { file, status, progress, error, compress } = state;
const isImage = file.type.startsWith("image/");
const thumbnail = useMemo(
() => (isImage ? URL.createObjectURL(file) : undefined),
[file, isImage]
);
useEffect(() => {
return () => {
if (thumbnail) URL.revokeObjectURL(thumbnail);
};
}, [thumbnail]);
return (
<Flex
sx={{
alignItems: "center",
py: 1,
px: 1,
gap: 2,
borderBottom: showDivider ? "1px solid var(--border)" : undefined
}}
>
{thumbnail ? (
<Image
src={thumbnail}
sx={{
width: 40,
height: 40,
objectFit: "cover",
borderRadius: "default",
flexShrink: 0
}}
/>
) : (
<Flex
sx={{
width: 40,
height: 40,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
bg: "background-secondary",
borderRadius: "default"
}}
>
<FileIcon size={20} color="icon" />
</Flex>
)}
<Flex sx={{ flex: 1, flexDirection: "column", minWidth: 0 }}>
<Text
variant="body"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}
>
{file.name}
</Text>
<Text variant="subBody" sx={{ color: "paragraph-secondary" }}>
{formatBytes(file.size)}
{status === "compressing"
? `${strings.compressing()}...`
: status === "encrypting"
? `${strings.encrypting()} ${progress}%`
: status === "error"
? `${error}`
: ""}
</Text>
</Flex>
<Flex sx={{ flexShrink: 0, alignItems: "center" }}>
{showCompressionToggle && isImage ? (
<Switch
sx={{
m: 0,
bg: compress ? "accent" : "icon-secondary",
flexShrink: 0,
scale: 0.75
}}
checked={compress}
onChange={onToggleCompress}
/>
) : showCompressionToggle && !isImage ? (
<Text variant="subBody" sx={{ color: "paragraph-secondary" }}>
N/A
</Text>
) : status === "done" ? (
<CheckCircle size={20} color="accent" />
) : status === "error" ? (
<CloseCircle size={20} color="accent-error" />
) : status === "encrypting" || status === "compressing" ? (
<Flex sx={{ alignItems: "center", gap: 1 }}>
<Box
sx={{
width: 60,
height: 4,
bg: "border",
borderRadius: "full",
overflow: "hidden"
}}
>
<Box
sx={{
width: `${status === "compressing" ? 50 : progress}%`,
height: "100%",
bg: "accent",
transition: "width 0.2s ease"
}}
/>
</Box>
</Flex>
) : (
<Loading size={16} color="icon" />
)}
</Flex>
</Flex>
);
}

View File

@@ -123,6 +123,7 @@ export const ConfirmDialog = DialogManager.register(function ConfirmDialog(
<Text
as="div"
variant="body"
sx={{ overflowWrap: "break-word" }}
dangerouslySetInnerHTML={{ __html: mdToHtml(message) }}
/>
) : null}

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