Compare commits

...

297 Commits

Author SHA1 Message Date
01zulfi
bc9b27c138 core: add index on pendingsyncitems.type
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-10 09:28:43 +05:00
01zulfi
15ca19ddd0 core: remove lazy access of pendingsyncitems table
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-09 14:36:39 +05:00
01zulfi
a54e3dfebd core: move inbox item processing outside the sync loop
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-07 10:20:57 +05:00
Abdullah Atta
d521eb3bd8 desktop: regenerate package-lock (#10051) 2026-07-03 05:45:48 +05:00
Abdullah Atta
20bf090c7e desktop: fix source generation for electron (#10046)
* desktop: fix source generation for electron

* ci: upload generated-sources as build artifact
2026-07-03 05:14:11 +05:00
01zulfi
6cc9b8d1e1 web: add top margin to unlock view error text (#10030)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-02 08:45:49 +05:00
01zulfi
e931423eba web: show success toast when copying note link in context menu (#10027)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-02 08:45:41 +05:00
01zulfi
743d47e9e9 web: verify old password before creating backup during password update (#10031)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-02 08:45:26 +05:00
Ammar Ahmed
37c96177a7 Mobile release/3.4.4 (#10040)
* mobile: add bump-version script

* mobile: bump version to v3.4.4
2026-07-01 15:54:10 +05:00
Abdullah Atta
834479d095 Release v3.4.2 (#10039)
* web: bump version to 3.4.2

* desktop: fix `Invalid filename` error on backup creation
2026-07-01 12:51:34 +05:00
Abdullah Atta
643515c6c4 desktop: fix Invalid filename error on backup creation 2026-07-01 12:28:45 +05:00
Abdullah Atta
0b0a4f1b6f web: bump version to 3.4.2 2026-07-01 11:52:50 +05:00
Ammar Ahmed
48d88069c0 core: make db migration more resilient and recoverable (#10037)
* core: do not throw error if inboxitemshistory table already exists during migration

* core: add ensure column check to avoid `column already exists` errors

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-07-01 11:51:24 +05:00
Abdullah Atta
9d7754f1e1 ci: fix macos x64 build (#10038) 2026-07-01 08:54:05 +05:00
Abdullah Atta
b2ed115011 Merge pull request #10034 from streetwriters/release/3.4.1
Release v3.4.1
2026-06-30 20:32:34 +05:00
Abdullah Atta
3447e8133c web: bump version to 3.4.1 2026-06-30 20:15:54 +05:00
Abdullah Atta
4473d8ec67 ci: fix linux builds 2026-06-30 20:14:53 +05:00
01zulfi
08cf21b05a web: compress image in realtime for better UX in attach files dialog (#9899)
* web: compress image in realtime for better UX in attach files dialog
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: refactor

* web: fix "done" button is disabled

* theme: improve switch styles

* web: improve bulk attachment processing & ux

* web: fix `attachFile` is not a function

---------

Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
2026-06-30 19:31:42 +05:00
01zulfi
10dccba5b6 docs: include ColorNote & UpNote pages in importer section (#10029)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-30 15:22:00 +05:00
01zulfi
23c9a98013 docs: remove inbox api beta warning (#10028)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-30 13:43:04 +05:00
01zulfi
473fae4a30 Merge pull request #9995 from streetwriters/release/3.4.0
web: bump version to 3.4.0
2026-06-30 13:16:48 +05:00
Ammar Ahmed
e9808c08f0 mobile: bump version 2026-06-30 11:13:02 +05:00
Abdullah Atta
4bb46ea149 ci: fix build for windows 2026-06-30 10:02:44 +05:00
Ammar Ahmed
01811372fc mobile: prepare for release 2026-06-30 09:43:29 +05:00
Abdullah Atta
6d2097259b editor: remove .replaceAll usage to support older Android 9 devices 2026-06-30 09:43:29 +05:00
Abdullah Atta
4c3e065928 core: add comment about why we aren't lowercasing email for old password generation 2026-06-30 09:43:29 +05:00
Abdullah Atta
e45b72a8c4 ci: install sqlite modules in apps/desktop dir 2026-06-30 09:43:29 +05:00
Abdullah Atta
aceb6db8b8 core: always lowercase email address when resetting password 2026-06-30 09:43:29 +05:00
Abdullah Atta
5a0ca0bed9 ci: fix sqlite modules not being found in desktop builds 2026-06-30 09:43:29 +05:00
Abdullah Atta
8dd5779308 ci: fix desktop tests 2026-06-30 09:43:29 +05:00
Abdullah Atta
dc958098ea desktop: fix tests 2026-06-30 09:43:29 +05:00
Ammar Ahmed
bf536eb183 mobile: fix event subscription 2026-06-30 09:43:29 +05:00
Ammar Ahmed
eb3eea031e mobile: add support for copying links as html links with proper title. 2026-06-30 09:43:29 +05:00
Ammar Ahmed
d376e00a1e editor: cleanup nn:// links when pasted
Ensure that we remove any styles added to deep links. This is done especially for iOS where pasted link html get's wrapped into custom div with some hard coded styles making the link not match style with the rest of content.
2026-06-30 09:43:29 +05:00
Ammar Ahmed
aca36b60b7 editor: fix note links not detected on paste/edit 2026-06-30 09:43:29 +05:00
Ammar Ahmed
033666795c mobile: fix screenguard module error 2026-06-30 09:43:29 +05:00
Abdullah Atta
c0872c98a8 desktop: fix deep linking not working 2026-06-30 09:43:29 +05:00
01zulfi
f2173a42d9 web: add confirmation before deleting failed inbox items
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-30 09:43:29 +05:00
Ammar Ahmed
cbddea4518 mobile: show toast message on vault locked/unlocked events 2026-06-30 09:43:03 +05:00
Ammar Ahmed
c69304781a mobile: fix password input is not shown in vault dialog 2026-06-30 09:43:03 +05:00
Ammar Ahmed
412631bf67 core: dispatch vaultUnlocked event when password is updated. 2026-06-30 09:43:03 +05:00
Abdullah Atta
8c2be00920 core: do not send PushCompletedV2 if no items sent 2026-06-30 09:43:03 +05:00
01zulfi
553e2060be editor: copy html for internal links in copy link tool
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-30 09:43:03 +05:00
Abdullah Atta
7603de25c6 ci: fix Could not find any Visual Studio installation to use 2026-06-30 09:43:03 +05:00
01zulfi
146216949d web: update publish view popup when monographs sync (#10007)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-30 09:43:03 +05:00
Abdullah Atta
c4d8abef16 setup: add node-gyp-build to postinstall whitelist 2026-06-30 09:43:03 +05:00
Abdullah Atta
baf823264b core: remove debug tests 2026-06-30 09:43:03 +05:00
Abdullah Atta
7f0a517ee1 config: add better-sqlite3 to postinstall whitelist 2026-06-30 09:43:03 +05:00
01zulfi
da48cf0bd1 web: don't show monograph view count when self destruct enabled
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-30 09:43:03 +05:00
Ammar Ahmed
4c41e13c5d mobile: fix ts errors 2026-06-30 09:43:03 +05:00
01zulfi
82e2953bc8 web: fix paragraph color in inbox settings for dark mode
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-30 09:43:03 +05:00
01zulfi
fc244b1196 core: delete linked inboxHistoryItem's on permanent note deletion
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-30 09:43:03 +05:00
01zulfi
98ecad3450 web: bump version to 3.4.0
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-30 09:43:03 +05:00
Abdullah Atta
54dcbd9be6 web: bump importer to 2.4.5 2026-06-30 09:42:42 +05:00
Abdullah Atta
d26c99c487 web: continue importing if an error occurs but highlight the error clearly 2026-06-30 09:42:42 +05:00
01zulfi
0922d9d833 web: support colornote importer (#9985)
* docs: add import from colornote page
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: add support for colornote importer
* add colornote password input field
* add support for importing reminders
* fix archived notes import

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

* docs: update import from colornote page
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* web: use PromptDialog instead of field in colornote importer's password option
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

* docs: code review suggestion
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

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

---------

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-30 09:42:42 +05:00
01zulfi
0cdeee80fa docs: add import from upnote page (#9272)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-30 09:42:42 +05:00
Abdullah Atta
936b6cb4aa Merge branch 'master' into beta 2026-06-23 08:34:57 +05:00
Abdullah Atta
f9f974c9f0 Merge pull request #9429 from streetwriters/web/fix-vault-deletion-for-not-logged-in-user
web: don't ask for account password for vault deletion for not logged in users
2026-06-19 08:46:18 +05:00
01zulfi
e3816234ff monograph: show error message for empty password on unlock (#9927)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-18 14:17:33 +05:00
01zulfi
8b36ae6dee core: fix "table already exists" error in migrations (#9987)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-18 09:45:22 +05:00
Abdullah Atta
0bd030dc78 Merge pull request #9984 from streetwriters/fix/disable-ignore-edit-save
Editor transactions with `ignoreEdit` will no longer trigger save
2026-06-18 09:17:32 +05:00
Ammar Ahmed
63eb42eb9e mobile: disable save when ignoreEdit is true 2026-06-18 08:40:52 +05:00
Abdullah Atta
eb75df360f web: editor transactions with ignoreEdit will no longer trigger save 2026-06-17 21:44:34 +05:00
Ammar Ahmed
89f3a7945b mobile: update styling for checkbox 2026-06-17 09:21:05 +05:00
Ammar Ahmed
7af1387691 mobile: fix delete all notes check truncated 2026-06-17 09:18:21 +05:00
Ammar Ahmed
1d292d740c mobile: fix vault asks for fingerprint permission on creating vault 2026-06-17 09:03:20 +05:00
Ammar Ahmed
4f884a8411 mobile: do not ask for password on vault deletion if user not logged in 2026-06-17 09:03:19 +05:00
01zulfi
3122e8a379 web: don't ask for account password for vault deletion for not logged in users
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-06-17 09:02:30 +05:00
Ammar Ahmed
528812d69d Merge branch 'master' into beta 2026-06-16 12:54:30 +05:00
Ammar Ahmed
ab5391fd6e Merge pull request #9971 from streetwriters/fix/331-358
fix date picker modal renders blank if themVariant is not set based on app's theme mode
2026-06-16 12:39:22 +05:00
Ammar Ahmed
770d290af1 Merge pull request #9892 from kashaf-ansari-dev/fix-remove-unset-expiry-date
mobile: fix unset note expiry date
2026-06-16 12:38:22 +05:00
kashaf-ansari-dev
3db6c2b1fd mobile: remove today/tomorrow logic and set default expiry initial date to 1 week
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-16 12:35:58 +05:00
kashaf-ansari-dev
19a11dae26 mobile: fix expiry date picker width
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-16 12:35:58 +05:00
kashaf-ansari-dev
07d7a537e4 mobile: display expiry date on notes item
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-16 12:35:58 +05:00
kashaf-ansari-dev
20f39b82c6 mobile: add success messages on setting or removing expiry date
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-16 12:35:58 +05:00
kashaf-ansari-dev
9e5ed67eb3 mobile: prevent crash in note properties when expiryDate is undefined
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-16 12:34:53 +05:00
kashaf-ansari-dev
6ddd5078ac mobile: fix unset note expiry date
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-16 12:34:53 +05:00
Ammar Ahmed
579ae294b6 Merge pull request #9955 from kashaf-ansari-dev/fix-published-note-updated-title
mobile: reflected published note title in published section
2026-06-16 12:26:25 +05:00
Ammar Ahmed
6418476ed6 Merge pull request #9932 from kashaf-ansari-dev/fix-display-monograph-view-count
mobile: display monograph view count
2026-06-16 12:25:47 +05:00
Ammar Ahmed
dfc01fa558 Merge pull request #9931 from kashaf-ansari-dev/fix-vault-dialog-form-input
mobile: replace Input with FormInput in VaultDialog for inline field …
2026-06-16 12:24:09 +05:00
01zulfi
b4f3593966 web: fix recovery view header overflow on small screens (#9975)
* web: fix recovery view header overflow on small screens
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>

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

* mobile: bump version

* mobile: hide inbox settings when inbox api is disabled

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

* mobile: add validation for creating api key name input

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

* mobile: fix typo Notesbook to Notesnook

* mobile: add missing setup inbox keys screen

* mobile: update linkedom version

* mobile: fix ts errors

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

* mobile: fix expired text not visible in light mode

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

* mobile: enable save button only if keys are edited

* mobile: make failed inbox item error json vertically scrollable

* mobile: fix inbox pgp keys title text mismatch

* mobile: ask user password before showing pgp keys

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

* intl: cleanup

* mobile: show generic error below input fields

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

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

* core: add extensive logging around sync

* mobile: fix scrollview in sheet

---------

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

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

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

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

* docs(help): improve inbox api docs

---------

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

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

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

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

* core: pubilsh-info -> metadata

* monograph: add support for slug

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

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

* core: use v2 endpoint for publishing monographs

* monograph: separate route for v2 monographs

* core: fix monograph updates

---------

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

View File

@@ -146,6 +146,7 @@ jobs:
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu x64 sqlite-better-trigram
npm i --cpu x64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Build Electron wrapper
@@ -189,6 +190,7 @@ jobs:
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu arm64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Build Electron wrapper
@@ -208,7 +210,7 @@ jobs:
build-windows:
name: Build for Windows
needs: build
runs-on: windows-latest
runs-on: windows-2022
outputs:
windows-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
steps:

View File

@@ -107,7 +107,7 @@ jobs:
target_commitish: ${{ github.ref }}
- name: Generate flatpak sources
if: inputs.publish-github && inputs.build-linux
if: inputs.build-linux
run: |
sudo apt-get update
sudo apt-get install -y --quiet flatpak flatpak-builder pipx
@@ -127,6 +127,13 @@ jobs:
files: ./generated-sources.json
prerelease: ${{ endsWith(steps.app_metadata.outputs.app_version, '-beta') }}
- name: Upload generated-sources.json as artifact
uses: actions/upload-artifact@v4
if: inputs.build-linux
with:
name: generated-sources
path: ./generated-sources.json
build-macos:
name: Build for macOS
needs: build
@@ -182,7 +189,9 @@ jobs:
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu arm64 sqlite3-fts5-html
npm i --cpu x64 sqlite-better-trigram
npm i --cpu x64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Install provisioning profile
@@ -264,6 +273,7 @@ jobs:
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu x64 sqlite-better-trigram
npm i --cpu x64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Build Electron wrapper
@@ -331,6 +341,7 @@ jobs:
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu arm64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Build Electron wrapper
@@ -363,7 +374,7 @@ jobs:
name: Build for Windows
needs: build
if: inputs.build-windows
runs-on: windows-latest
runs-on: windows-2022
steps:
- name: Check out Git repository

View File

@@ -18,11 +18,12 @@ on:
- ".github/workflows/desktop.tests.yml"
jobs:
build:
name: Build
runs-on: ubuntu-22.04
test-macos-x64:
name: Test macOS x64
runs-on: macos-15-intel
steps:
- name: Checkout
- name: Check out Git repository
uses: actions/checkout@v4
- name: Setup Node
@@ -33,44 +34,10 @@ jobs:
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=web
- name: Generate desktop build
run: npm run tx @notesnook/web:build:desktop
- name: Archive build artifact
uses: actions/upload-artifact@v4
with:
name: build
path: apps/web/build/**/*
test-macos-x64:
name: Test macOS x64
needs: build
runs-on: macos-15-intel
steps:
- name: Check out Git repository
uses: actions/checkout@v4
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Download build
uses: actions/download-artifact@v4
with:
name: build
path: ./apps/web/build
- name: Install packages
- name: Install sqlite-better-trigram for all arch
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=desktop
- name: Build Electron wrapper
run: npm run tx @notesnook/desktop:release
- name: Build app
run: |
yarn electron-builder --config=electron-builder.config.js --mac --dir --x64
npm i --cpu x64 sqlite-better-trigram
npm i --cpu x64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Run tests x64
@@ -87,7 +54,6 @@ jobs:
test-macos:
name: Test macOS
needs: build
runs-on: macos-latest
steps:
@@ -97,23 +63,15 @@ jobs:
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Download build
uses: actions/download-artifact@v4
with:
name: build
path: ./apps/web/build
- name: Install packages
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=desktop
npm run bootstrap -- --scope=web
- name: Build Electron wrapper
run: npm run tx @notesnook/desktop:release
- name: Build app
- name: Install sqlite-better-trigram for all arch
run: |
yarn electron-builder --config=electron-builder.config.js --mac --dir --arm64
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu arm64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Run tests arm64
@@ -130,7 +88,6 @@ jobs:
test-linux:
name: Test for Linux
needs: build
runs-on: ubuntu-22.04
steps:
@@ -140,29 +97,15 @@ jobs:
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Download build
uses: actions/download-artifact@v4
with:
name: build
path: ./apps/web/build
- name: Install packages
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=desktop
npm run bootstrap -- --scope=web
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu x64 sqlite-better-trigram
working-directory: ./apps/desktop
- name: Build Electron wrapper
run: npm run tx @notesnook/desktop:release
- name: Build app
run: |
yarn electron-builder --config=electron-builder.config.js --linux --dir --arm64 --x64
npm i --cpu x64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Run tests
@@ -179,8 +122,7 @@ jobs:
test-windows:
name: Test for Windows
needs: build
runs-on: windows-latest
runs-on: windows-2022
steps:
- name: Check out Git repository
@@ -189,34 +131,17 @@ jobs:
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Download build
uses: actions/download-artifact@v4
with:
name: build
path: ./apps/web/build
- name: Install packages
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=desktop
npm run bootstrap -- --scope=web
- name: Install sqlite-better-trigram for all arch
run: |
npm i --cpu arm64 sqlite-better-trigram
npm i --cpu x64 sqlite-better-trigram
npm i --cpu arm64 sqlite3-fts5-html
npm i --cpu x64 sqlite3-fts5-html
working-directory: ./apps/desktop
- name: Build Electron wrapper
run: node scripts/execute.mjs @notesnook/desktop:release
- name: Build app
run: |
npx cross-env NOTESNOOK_STAGING=true yarn electron-builder --config=electron-builder.config.js --win --dir --arm64 --x64
working-directory: ./apps/desktop
- name: Run tests
run: npm run test
working-directory: ./apps/desktop

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,9 +17,10 @@ You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { SQLite } from "@notesnook/desktop";
import { expose } from "comlink";
export type SQLiteWorker = typeof SQLite.prototype;
const db = new SQLite();
expose(db);
const ansiRegex = new RegExp(
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))",
"g"
);
export function stripAnsi(str: string): string {
return str.replace(ansiRegex, "");
}

View File

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

View File

@@ -0,0 +1,52 @@
/* eslint-disable header/header */
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
/// @ts-ignore
import { oop, client } from "playwright-core/lib/coreBundle";
export type TestModeName = "default" | "driver";
const { start } = oop;
interface TestMode {
setup(): Promise<client.Playwright>;
teardown(): Promise<void>;
}
export class DriverTestMode implements TestMode {
private _impl: { playwright: client.Playwright; stop: () => Promise<void> };
async setup() {
this._impl = await start({
NODE_OPTIONS: undefined // Hide driver process while debugging.
});
return this._impl.playwright;
}
async teardown() {
await this._impl.stop();
}
}
export class DefaultTestMode implements TestMode {
async setup() {
return require("playwright-core");
}
async teardown() {}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,24 @@
diff --git a/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js b/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js
index 7d8468f..ab252ef 100644
--- a/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js
+++ b/node_modules/@playwright/test/node_modules/playwright-core/lib/coreBundle.js
@@ -16010,6 +16010,7 @@ var init_validator = __esm({
args: tOptional(tArray(tString)),
chromiumSandbox: tOptional(tBoolean),
cwd: tOptional(tString),
+ baseURL: tOptional(tString),
env: tOptional(tArray(tType("NameValue"))),
timeout: tFloat,
acceptDownloads: tOptional(tEnum(["accept", "deny", "internal-browser-default"])),
diff --git a/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts b/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts
index 0bff9d3..d195697 100644
--- a/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts
+++ b/node_modules/@playwright/test/node_modules/playwright-core/types/types.d.ts
@@ -21813,6 +21813,7 @@ export interface Electron {
* Additional arguments to pass to the application when launching. You typically pass the main script name here.
*/
args?: Array<string>;
+ baseURL?: string;
/**
* If specified, artifacts (traces, videos, downloads, HAR files, etc.) are saved into this directory. The directory

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,11 +20,30 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import { app } from "electron";
import { isAbsolute, join } from "path";
export function normalizePathString(_path: string) {
let normalizedPath = _path.trim();
try {
const parsedPath = JSON.parse(normalizedPath);
if (typeof parsedPath === "string") normalizedPath = parsedPath;
} catch {
// ignore invalid JSON and keep the original path string
}
if (normalizedPath === "~") return app.getPath("home");
if (/^~[\\/]/.test(normalizedPath)) {
return join(app.getPath("home"), normalizedPath.slice(2));
}
return normalizedPath;
}
export function resolvePath(_path: string) {
if (isAbsolute(_path)) return _path;
const normalizedPath = normalizePathString(_path);
if (isAbsolute(normalizedPath)) return normalizedPath;
return join(
..._path.split("/").map((segment) => {
...normalizedPath.split("/").map((segment) => {
let resolved = segment;
try {
resolved = app.getPath(resolved as any);

View File

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

View File

@@ -1,42 +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 { defineConfig } from "vitest/config";
export default defineConfig({
test: {
testTimeout: 120 * 1000,
hookTimeout: 120 * 1000,
sequence: {
concurrent: true,
shuffle: true
},
globalSetup: "./__tests__/global-setup.ts",
dir: "./__tests__/",
exclude: [
"**/node_modules/**",
"**/dist/**",
"**/cypress/**",
"**/.{idea,git,cache,output,temp}/**",
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*",
"**/test-results/**",
"**/test-artifacts/**"
]
}
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
@@ -191,7 +192,8 @@ export async function checkAndCreateDir(path: string) {
}
export const santizeUri = (uri: string) => {
return Platform.OS === "ios" ? decodeURI(uri).replace("file:///", "/") : uri;
const decoded = decodeURI(uri);
return Platform.OS === "ios" ? decoded.replace("file:///", "/") : decoded;
};
export function isSuccessStatusCode(statusCode: number) {

View File

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

View File

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

View File

@@ -51,7 +51,10 @@ import DialogButtons from "../../dialog/dialog-buttons";
import DialogHeader from "../../dialog/dialog-header";
import { Toast } from "../../toast";
import { Button } from "../../ui/button";
import Input from "../../ui/input";
import FormInput, {
createFormRef,
validators
} from "../../ui/input/form-input";
import Seperator from "../../ui/seperator";
import Paragraph from "../../ui/typography/paragraph";
import { strings } from "@notesnook/intl";
@@ -63,15 +66,18 @@ import {
VAULT_ERRORS
} from "@notesnook/core";
import { useThemeColors } from "@notesnook/theme";
import { useUserStore } from "../../../stores/use-user-store";
import { AppFontSize } from "../../../utils/size";
import { Pressable } from "../../ui/pressable";
import AppIcon from "../../ui/AppIcon";
export const VaultDialog: React.FC = () => {
const { colors } = useThemeColors();
// UI State
const [visible, setVisible] = useState(false);
const isUserLoggedIn = useUserStore((state) => !!state.user);
const [loading, setLoading] = useState(false);
const [wrongPassword, setWrongPassword] = useState(false);
const [passwordsDontMatch, setPasswordsDontMatch] = useState(false);
const [deleteAll, setDeleteAll] = useState(false);
const [biometricUnlock, setBiometricUnlock] = useState(false);
const [isBiometryAvailable, setIsBiometryAvailable] = useState(false);
@@ -100,15 +106,19 @@ export const VaultDialog: React.FC = () => {
| undefined
>(undefined);
// Form ref
const formRef = useRef(
createFormRef({
password: "",
confirmPassword: "",
newPassword: ""
})
);
// Input refs
const passInputRef = useRef<TextInput>(null);
const confirmPassRef = useRef<TextInput>(null);
const changePassInputRef = useRef<TextInput>(null);
// Password refs
const passwordRef = useRef<string | null>(null);
const confirmPasswordRef = useRef<string | null>(null);
const newPasswordRef = useRef<string | null>(null);
const newPassInputRef = useRef<TextInput>(null);
const close = useCallback(() => {
if (loading) {
@@ -123,10 +133,11 @@ export const VaultDialog: React.FC = () => {
Navigation.queueRoutesForUpdate();
// Reset password refs
passwordRef.current = null;
confirmPasswordRef.current = null;
newPasswordRef.current = null;
// Reset form values and errors
formRef.current.setValue("password", "");
formRef.current.setValue("confirmPassword", "");
formRef.current.setValue("newPassword", "");
formRef.current.clearErrors();
// Reset refs
requestTypeRef.current = null;
@@ -144,8 +155,6 @@ export const VaultDialog: React.FC = () => {
// Reset UI state
setVisible(false);
setLoading(false);
setWrongPassword(false);
setPasswordsDontMatch(false);
setDeleteAll(false);
setBiometricUnlock(false);
setIsBiometryAvailable(false);
@@ -155,9 +164,10 @@ export const VaultDialog: React.FC = () => {
const deleteVault = useCallback(async () => {
setLoading(true);
try {
const { password } = formRef.current.getValues();
let verified = true;
if (await db.user.getUser()) {
verified = await db.user.verifyPassword(passwordRef.current || "");
verified = await db.user.verifyPassword(password);
}
if (verified) {
let noteIds: string[] = [];
@@ -175,6 +185,7 @@ export const VaultDialog: React.FC = () => {
noteIds = relations.map((item) => item.toId);
}
await db.vault.delete(deleteAll);
await BiometricService.resetCredentials();
if (deleteAll) {
noteIds.forEach((id) => {
@@ -190,16 +201,15 @@ export const VaultDialog: React.FC = () => {
}
eSendEvent("vaultUpdated");
setLoading(false);
setTimeout(() => {
close();
}, 100);
close();
ToastManager.show({
message: strings.vaultDeleted(),
type: "success",
context: "global"
});
} else {
setLoading(false);
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
formRef.current.setError("password", strings.passwordIncorrect());
}
} catch (e) {
console.error(e);
@@ -209,11 +219,12 @@ export const VaultDialog: React.FC = () => {
const clearVault = useCallback(async () => {
setLoading(true);
try {
const { password } = formRef.current.getValues();
const vault = await db.vaults.default();
const relations = await db.relations.from(vault!, "note").get();
const noteIds = relations.map((item) => item.toId);
await db.vault.clear(passwordRef.current || "");
await db.vault.clear(password);
noteIds.forEach((id) => {
eSendEvent(
@@ -233,11 +244,7 @@ export const VaultDialog: React.FC = () => {
type: "success"
});
} catch (e) {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
formRef.current.setError("password", strings.passwordIncorrect());
}
setLoading(false);
}, [close]);
@@ -257,12 +264,7 @@ export const VaultDialog: React.FC = () => {
});
close();
} catch (e) {
close();
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
formRef.current.setError("password", strings.passwordIncorrect());
setLoading(false);
}
},
@@ -270,24 +272,14 @@ export const VaultDialog: React.FC = () => {
);
const takeErrorAction = useCallback(() => {
setWrongPassword(true);
formRef.current.setError("password", strings.passwordIncorrect());
setVisible(true);
setTimeout(() => {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
}, 500);
}, []);
const lockNote = useCallback(async () => {
if (!passwordRef.current || passwordRef.current.trim() === "") {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
const { password } = formRef.current.getValues();
if (!password || password.trim() === "") {
formRef.current.setError("password", strings.passwordIncorrect());
return;
} else {
await db.vault.add(noteRef.current!.id);
@@ -305,8 +297,9 @@ export const VaultDialog: React.FC = () => {
}, [close]);
const permanantUnlock = useCallback(() => {
const { password } = formRef.current.getValues();
db.vault
.remove(noteRef.current!.id, passwordRef.current || "")
.remove(noteRef.current!.id, password)
.then(async () => {
ToastManager.show({
heading: strings.noteUnlocked(),
@@ -315,7 +308,7 @@ export const VaultDialog: React.FC = () => {
});
eSendEvent(eUpdateNoteInEditor, noteRef.current, true);
if (biometricUnlock && !isBiometryEnrolled) {
await enrollFingerprint(passwordRef.current || "");
await enrollFingerprint(password);
}
close();
})
@@ -375,8 +368,9 @@ export const VaultDialog: React.FC = () => {
);
const deleteNote = useCallback(async () => {
const { password } = formRef.current.getValues();
try {
await db.vault.remove(noteRef.current!.id, passwordRef.current || "");
await db.vault.remove(noteRef.current!.id, password);
await deleteItems("note", [noteRef.current!.id]);
close();
} catch (e) {
@@ -385,16 +379,14 @@ export const VaultDialog: React.FC = () => {
}, [close, takeErrorAction]);
const openNote = useCallback(async () => {
const { password } = formRef.current.getValues();
try {
if (!passwordRef.current) throw new Error("Invalid password");
if (!password) throw new Error("Invalid password");
const note = await db.vault.open(
noteRef.current!.id,
passwordRef.current
);
const note = await db.vault.open(noteRef.current!.id, password);
if (!note) throw new Error("Failed to unlock note.");
if (biometricUnlock && !isBiometryEnrolled) {
await enrollFingerprint(passwordRef.current || "");
await enrollFingerprint(password);
}
const requestType = requestTypeRef.current;
@@ -411,7 +403,6 @@ export const VaultDialog: React.FC = () => {
requestType === VaultRequestType.CustomAction &&
onUnlockRef.current
) {
const password = passwordRef.current;
const unlock = onUnlockRef.current;
close();
await sleep(500);
@@ -433,12 +424,9 @@ export const VaultDialog: React.FC = () => {
]);
const unlockNote = useCallback(async () => {
if (!passwordRef.current || passwordRef.current.trim() === "") {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
const { password } = formRef.current.getValues();
if (!password || password.trim() === "") {
formRef.current.setError("password", strings.passwordIncorrect());
return;
}
if (requestTypeRef.current === VaultRequestType.PermanentUnlock) {
@@ -449,10 +437,11 @@ export const VaultDialog: React.FC = () => {
}, [permanantUnlock, openNote]);
const createVault = useCallback(async () => {
await db.vault.create(passwordRef.current || "");
const { password } = formRef.current.getValues();
await db.vault.create(password);
if (biometricUnlock) {
await enrollFingerprint(passwordRef.current || "");
await enrollFingerprint(password);
}
if (noteRef.current?.id) {
await db.vault.add(noteRef.current.id);
@@ -504,36 +493,30 @@ export const VaultDialog: React.FC = () => {
if (loading) return;
if (!passwordRef.current) {
ToastManager.show({
heading: strings.passwordNotEntered(),
type: "error",
context: "local"
});
if (!formRef.current.validate()) return;
const { password, newPassword } = formRef.current.getValues();
if (requestType === VaultRequestType.DeleteVault && !isUserLoggedIn) {
setLoading(true);
await deleteVault();
setLoading(false);
return;
}
if (requestType === VaultRequestType.CreateVault) {
if (passwordRef.current !== confirmPasswordRef.current) {
ToastManager.show({
heading: strings.passwordNotMatched(),
type: "error",
context: "local"
});
setPasswordsDontMatch(true);
return;
}
createVault();
} else if (requestType === VaultRequestType.ChangePassword) {
setLoading(true);
db.vault
.changePassword(passwordRef.current, newPasswordRef.current || "")
.then(() => {
.changePassword(password, newPassword)
.then(async () => {
setLoading(false);
if (biometricUnlock) {
enrollFingerprint(newPasswordRef.current || "");
enrollFingerprint(newPassword);
} else {
await BiometricService.resetCredentials();
}
ToastManager.show({
heading: strings.passwordUpdated(),
@@ -545,37 +528,23 @@ export const VaultDialog: React.FC = () => {
.catch((e) => {
setLoading(false);
if (e.message === VAULT_ERRORS.wrongPassword) {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
formRef.current.setError("password", strings.passwordIncorrect());
} else {
ToastManager.error(e);
console.error(e);
}
});
} else if (requestType === VaultRequestType.LockNote) {
if (!passwordRef.current || passwordRef.current.trim() === "") {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
setWrongPassword(true);
return;
}
db.vault
.unlock(passwordRef.current)
.unlock(password)
.then(async (unlocked) => {
if (unlocked) {
setWrongPassword(false);
await lockNote();
} else {
takeErrorAction();
formRef.current.setError("password", strings.passwordIncorrect());
}
})
.catch((e) => {
takeErrorAction();
formRef.current.setError("password", strings.passwordIncorrect());
});
} else if (
requestType === VaultRequestType.UnlockNote ||
@@ -586,22 +555,13 @@ export const VaultDialog: React.FC = () => {
requestType === VaultRequestType.DeleteNote ||
requestType === VaultRequestType.CustomAction
) {
if (!passwordRef.current || passwordRef.current.trim() === "") {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error",
context: "local"
});
setWrongPassword(true);
return;
}
if (noteLockedRef.current) {
await unlockNote();
} else {
console.log("Error: Note should be locked for this operation");
}
} else if (requestType === VaultRequestType.EnableFingerprint) {
enrollFingerprint(passwordRef.current);
enrollFingerprint(password);
} else if (requestType === VaultRequestType.ClearVault) {
await clearVault();
} else if (requestType === VaultRequestType.DeleteVault) {
@@ -616,7 +576,6 @@ export const VaultDialog: React.FC = () => {
enrollFingerprint,
unlockNote,
lockNote,
takeErrorAction,
clearVault,
deleteVault
]);
@@ -632,7 +591,7 @@ export const VaultDialog: React.FC = () => {
if (!credentials) throw new Error("Failed to get user credentials");
if (credentials?.password) {
passwordRef.current = credentials.password;
formRef.current.setValue("password", credentials.password);
onPress();
} else {
eSendEvent(eCloseActionSheet);
@@ -679,24 +638,28 @@ export const VaultDialog: React.FC = () => {
setIsBiometryAvailable(available);
setIsBiometryEnrolled(fingerprint);
setBiometricUnlock(fingerprint);
setWrongPassword(false);
setPasswordsDontMatch(false);
setDeleteAll(false);
setLoading(false);
// Auto-unlock with fingerprint if applicable
const canAutoUnlock =
fingerprint &&
available &&
data.requestType !== VaultRequestType.EnableFingerprint &&
data.requestType !== VaultRequestType.RevokeFingerprint &&
data.requestType !== VaultRequestType.ChangePassword &&
data.requestType !== VaultRequestType.ClearVault &&
data.requestType !== VaultRequestType.DeleteVault &&
data.requestType !== VaultRequestType.CustomAction &&
data.requestType !== VaultRequestType.PermanentUnlock;
data.requestType !== VaultRequestType.PermanentUnlock &&
data.requestType !== VaultRequestType.CreateVault;
if (canAutoUnlock) {
await onPressFingerprintAuth(data.title, data.description);
if (canAutoUnlock && available) {
try {
await onPressFingerprintAuth(data.title, data.description);
} catch (e) {
setVisible(true);
}
} else {
setVisible(true);
}
@@ -758,7 +721,6 @@ export const VaultDialog: React.FC = () => {
icon="shield"
padding={12}
/>
<Seperator half />
<View
style={{
@@ -767,19 +729,25 @@ export const VaultDialog: React.FC = () => {
>
{(isChangePassword ||
isClearVault ||
!isCreateVault ||
isDeleteVault ||
requestType === VaultRequestType.LockNote ||
requestType === VaultRequestType.UnlockNote ||
requestType === VaultRequestType.PermanentUnlock ||
isGoToEditor ||
isShareNote ||
isDeleteNote ||
isEnableFingerprint ||
(isDeleteVault && isUserLoggedIn) ||
isCustomAction) &&
!isRevokeFingerprint ? (
<>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passInputRef}
editable={!loading}
autoCapitalize="none"
testID={notesnook.ids.dialogs.vault.pwd}
onChangeText={(value) => {
passwordRef.current = value;
}}
autoComplete="password"
marginBottom={
!biometricUnlock ||
!isBiometryEnrolled ||
@@ -789,14 +757,13 @@ export const VaultDialog: React.FC = () => {
? 0
: 10
}
onSubmit={() => {
onSubmitEditing={() => {
if (isChangePassword) {
confirmPassRef.current?.focus();
newPassInputRef.current?.focus();
} else {
onPress();
}
}}
autoComplete="password"
returnKeyLabel={
isChangePassword ? strings.next() : titleRef.current
}
@@ -807,6 +774,7 @@ export const VaultDialog: React.FC = () => {
? strings.currentPassword()
: strings.password()
}
validators={[validators.required(strings.passwordRequired())]}
/>
{!biometricUnlock ||
@@ -830,89 +798,98 @@ export const VaultDialog: React.FC = () => {
) : null}
{isDeleteVault && (
<Button
<Pressable
onPress={() => setDeleteAll(!deleteAll)}
icon={
deleteAll
? "check-circle-outline"
: "checkbox-blank-circle-outline"
}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL
paddingVertical: 0,
flexDirection: "row",
gap: DefaultAppStyles.GAP_SMALL,
marginTop: isUserLoggedIn
? DefaultAppStyles.GAP_VERTICAL_SMALL
: 0,
justifyContent: "flex-start"
}}
width="100%"
title={strings.deleteAllNotes()}
type="errorShade"
/>
>
<AppIcon
name={
deleteAll
? "check-circle-outline"
: "checkbox-blank-circle-outline"
}
color={colors.error.accent}
size={AppFontSize.md}
/>
<Paragraph color={colors.error.accent} size={AppFontSize.sm}>
{strings.deleteAllNotes()}
</Paragraph>
</Pressable>
)}
{isChangePassword ? (
<>
<Seperator half />
<Input
fwdRef={confirmPassRef}
<FormInput
name="newPassword"
formRef={formRef}
fwdRef={newPassInputRef}
editable={!loading}
testID={notesnook.ids.dialogs.vault.changePwd}
autoCapitalize="none"
onChangeText={(value) => {
newPasswordRef.current = value;
}}
autoComplete="password"
onSubmit={() => {
onSubmitEditing={() => {
onPress();
}}
returnKeyLabel="Change"
returnKeyType="done"
secureTextEntry
placeholder={strings.newPassword()}
validators={[validators.required(strings.passwordRequired())]}
/>
</>
) : null}
{isCreateVault ? (
<View>
<Input
<FormInput
name="password"
formRef={formRef}
fwdRef={passInputRef}
autoCapitalize="none"
testID={notesnook.ids.dialogs.vault.pwd}
onChangeText={(value) => {
passwordRef.current = value;
}}
autoComplete="password"
returnKeyLabel={strings.next()}
returnKeyType="next"
secureTextEntry
onSubmit={() => {
onSubmitEditing={() => {
confirmPassRef.current?.focus();
}}
placeholder={strings.password()}
validators={[validators.required(strings.passwordRequired())]}
/>
<Input
<FormInput
name="confirmPassword"
formRef={formRef}
fwdRef={confirmPassRef}
autoCapitalize="none"
testID={notesnook.ids.dialogs.vault.pwdAlt}
secureTextEntry
validationType="confirmPassword"
customValidator={() => passwordRef.current || ""}
errorMessage="Passwords do not match."
onErrorCheck={() => null}
marginBottom={0}
autoComplete="password"
returnKeyLabel="Create"
returnKeyType="done"
onChangeText={(value) => {
confirmPasswordRef.current = value;
if (value !== passwordRef.current) {
setPasswordsDontMatch(true);
} else {
setPasswordsDontMatch(false);
}
}}
onSubmit={() => {
marginBottom={0}
onSubmitEditing={() => {
onPress();
}}
placeholder={strings.confirmPassword()}
validators={[
validators.required(strings.confirmPasswordRequired()),
validators.matchField(
"password",
strings.passwordNotMatched()
)
]}
/>
</View>
) : null}

View File

@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
import {
GroupHeader,
GroupingByIdKey,
GroupingKey,
GroupOptions,
ItemType
@@ -46,6 +47,8 @@ type SectionHeaderProps = {
screen?: RouteName;
groupOptions: GroupOptions;
group: GroupingKey;
groupId?: string;
type?: GroupingByIdKey;
onOpenJumpToDialog: () => void;
itemCount?: number;
};
@@ -62,7 +65,9 @@ export const SectionHeader = React.memo<
groupOptions,
group,
onOpenJumpToDialog,
itemCount
itemCount,
groupId,
type
}: SectionHeaderProps) {
const { colors } = useThemeColors();
const isCompactModeEnabled = useIsCompactModeEnabled(
@@ -143,8 +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"
}

View File

@@ -55,6 +55,7 @@ import { TimeSince } from "../../ui/time-since";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
import dayjs from "dayjs";
import { ExpiryDate } from "../../ui/expiry-date";
type NoteItemProps = {
item: Note | BaseTrashItem<Note>;
@@ -266,6 +267,21 @@ const NoteItem = ({
/>
) : null}
{item.expiryDate?.value ? (
<ExpiryDate
note={item as Note}
color={color?.colorCode}
textStyle={{ fontSize: AppFontSize.xxs }}
short
iconSize={AppFontSize.xxs}
style={{
justifyContent: "flex-start",
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL / 2,
alignSelf: "flex-start"
}}
/>
) : null}
{notebooks?.items
?.filter(
(item) =>

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";
@@ -55,6 +60,7 @@ type ListProps = {
placeholder?: PlaceholderData;
groupType: GroupingKey;
id?: string;
type?: GroupingByIdKey;
};
const onMomentumScrollEnd = () => {
@@ -74,7 +80,7 @@ export default function List(props: ListProps) {
props.dataType === "notebook" ||
notebooksListMode === "compact";
const groupOptions = useGroupOptions(props.groupType);
const groupOptions = useGroupOptions(props.groupType, props.id, props.type);
const _onRefresh = async () => {
Sync.run("global", false, "full", () => {
@@ -96,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={props.groupType as GroupingKey}
renderedInRoute={props.renderedInRoute}
customAccentColor={props.customAccentColor}
dataType={props.dataType}
type={props.type}
scrollRef={scrollRef}
/>
);
},
[
groupOptions,
props.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,8 +50,8 @@ 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;
@@ -62,6 +63,8 @@ type ListItemWrapperProps<TItem = Item> = {
dataType: string;
scrollRef: any;
groupOptions: GroupOptions;
groupId?: string;
type?: GroupingByIdKey;
};
export function ListItemWrapper(props: ListItemWrapperProps) {
@@ -183,6 +186,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
index={index}
dataType={item.type}
group={group}
groupId={props.groupId}
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
@@ -220,6 +225,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
index={index}
dataType={item.type}
group={group}
groupId={props.groupId}
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
@@ -250,6 +257,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
group={group}
dataType={item.type}
color={props.customAccentColor}
type={props.type}
groupId={props.groupId}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
eSendEvent(eOpenJumpToDialog, {
@@ -276,6 +285,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
index={index}
group={group}
dataType={item.type}
groupId={props.groupId}
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
onOpenJumpToDialog={() => {
@@ -303,6 +314,8 @@ export function ListItemWrapper(props: ListItemWrapperProps) {
index={index}
group={group}
dataType={item.type}
groupId={props.groupId}
type={props.type}
color={props.customAccentColor}
groupOptions={groupOptions}
itemCount={items?.placeholders.length}
@@ -322,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

@@ -176,6 +176,7 @@ const PayWall = (props: NavigationProps<"PayWall">) => {
>
<IconButton
name="close"
testID="paywall-close"
color={colors.primary.icon}
onPress={() => {
Navigation.navigate("FluidPanelsView", {});

View File

@@ -110,6 +110,7 @@ export const DateMeta = ({ item }: { item: Item }) => {
}}
maximumDate={new Date((item as Note).dateEdited)}
isDarkModeEnabled={isDark}
themeVariant={isDark ? "dark" : "light"}
is24Hour={db.settings.getTimeFormat() === "24-hour"}
date={new Date(dateCreated)}
/>

View File

@@ -42,7 +42,8 @@ const TOP_BAR_ITEMS: ActionId[] = [
"publish",
"local-only",
"read-only",
"pin-to-notifications"
"pin-to-notifications",
"spell-check"
];
const BOTTOM_BAR_ITEMS: ActionId[] = [
@@ -79,6 +80,8 @@ const COLUMN_BAR_ITEMS: ActionId[] = [
"rename-color",
"rename-tag",
"launcher-shortcut",
"copy-id",
"copy-link",
"restore",
"trash",
"delete"

View File

@@ -0,0 +1,183 @@
/*
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, { useRef, useState } from "react";
import { View } from "react-native";
import { strings } from "@notesnook/intl";
import { db } from "../../../common/database";
import { Button } from "../../ui/button";
import FormInput, {
createFormRef,
validators
} from "../../ui/input/form-input";
import Paragraph from "../../ui/typography/paragraph";
import Heading from "../../ui/typography/heading";
import { DefaultAppStyles } from "../../../utils/styles";
import { AppFontSize } from "../../../utils/size";
import { ToastManager, presentSheet } from "../../../services/event-manager";
import { useThemeColors } from "@notesnook/theme";
import { Pressable } from "../../ui/pressable";
import { ScrollView } from "react-native-actions-sheet";
const getExpiryOptions = () => [
{ label: strings.expiryOneDay(), value: 24 * 60 * 60 * 1000 },
{ label: strings.expiryOneWeek(), value: 7 * 24 * 60 * 60 * 1000 },
{ label: strings.expiryOneMonth(), value: 30 * 24 * 60 * 60 * 1000 },
{ label: strings.expiryOneYear(), value: 365 * 24 * 60 * 60 * 1000 },
{ label: strings.never(), value: -1 }
];
type AddApiKeySheetProps = {
close?: (ctx?: string | undefined) => void;
onAdd: () => void;
};
export default function AddApiKeySheet({ close, onAdd }: AddApiKeySheetProps) {
const { colors } = useThemeColors();
const formRef = useRef(
createFormRef({
keyName: ""
})
);
const [selectedExpiry, setSelectedExpiry] = useState(
getExpiryOptions()[2].value
);
const [isCreating, setIsCreating] = useState(false);
const handleCreate = async () => {
try {
if (formRef.current.validateField("keyName")) {
return;
}
const keyName = formRef.current.getValue("keyName").trim();
setIsCreating(true);
await db.inboxApiKeys.create(keyName, selectedExpiry);
ToastManager.show({
message: strings.apiKeyCreatedSuccessfully(),
type: "success"
});
onAdd();
close?.();
} catch (error) {
const message = error instanceof Error ? error.message : "";
ToastManager.show({
message: strings.failedToCreateApiKey(message),
type: "error"
});
formRef.current.setError(
"keyName",
message || strings.failedToCreateApiKey("")
);
} finally {
setIsCreating(false);
}
};
return (
<ScrollView
contentContainerStyle={{
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL,
paddingTop: DefaultAppStyles.GAP_VERTICAL,
paddingBottom: DefaultAppStyles.GAP_VERTICAL * 2
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center"
}}
>
<Heading size={AppFontSize.xl}>{strings.createApiKey()}</Heading>
</View>
<View style={{ gap: DefaultAppStyles.GAP_VERTICAL }}>
<Paragraph size={AppFontSize.sm}>{strings.keyName()}</Paragraph>
<FormInput
name="keyName"
formRef={formRef}
placeholder={strings.exampleKeyName()}
validators={[validators.required(strings.enterKeyName())]}
onChangeText={() => {
formRef.current.setError("keyName", undefined);
}}
onSubmitEditing={handleCreate}
/>
</View>
<View style={{ gap: DefaultAppStyles.GAP_VERTICAL }}>
<Paragraph size={AppFontSize.sm}>{strings.expiresIn()}</Paragraph>
<View
style={{
flexDirection: "row",
flexWrap: "wrap",
gap: DefaultAppStyles.GAP_SMALL
}}
>
{getExpiryOptions().map((option) => (
<Pressable
key={option.label}
onPress={() => setSelectedExpiry(option.value)}
type={
selectedExpiry === option.value ? "selected" : "transparent"
}
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
paddingHorizontal: DefaultAppStyles.GAP
}}
>
<Paragraph
size={AppFontSize.sm}
color={
selectedExpiry === option.value
? colors.selected.paragraph
: colors.primary.paragraph
}
>
{option.label}
</Paragraph>
</Pressable>
))}
</View>
</View>
<Button
title={isCreating ? strings.creating() : strings.create()}
type="accent"
width="100%"
loading={isCreating}
disabled={isCreating}
onPress={handleCreate}
style={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
/>
</ScrollView>
);
}
AddApiKeySheet.present = (onAdd: () => void) => {
presentSheet({
component: (ref, close, _update) => (
<AddApiKeySheet close={close} onAdd={onAdd} />
)
});
};

View File

@@ -33,11 +33,11 @@ import { editorController } from "../../../screens/editor/tiptap/utils";
import { eSendEvent, presentSheet } from "../../../services/event-manager";
import { eUnlockNote } from "../../../utils/events";
import { AppFontSize } from "../../../utils/size";
import { DefaultAppStyles } from "../../../utils/styles";
import { IconButton } from "../../ui/icon-button";
import { Pressable } from "../../ui/pressable";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
import { DefaultAppStyles } from "../../../utils/styles";
const TabItemComponent = (props: {
tab: TabItem;
@@ -225,14 +225,24 @@ export default function EditorTabs({
}}
>
<Heading size={AppFontSize.lg}>{strings.tabs()}</Heading>
<IconButton
onPress={() => {
useTabStore.getState().newTab();
close?.();
}}
name="plus"
color={colors.primary.accent}
/>
<View style={{ flexDirection: "row", gap: DefaultAppStyles.GAP_SMALL }}>
<IconButton
onPress={() => {
useTabStore.getState().clearAllTabs();
close?.();
}}
name="close-box-multiple-outline"
color={colors.primary.icon}
/>
<IconButton
onPress={() => {
useTabStore.getState().newTab();
close?.();
}}
name="plus"
color={colors.primary.accent}
/>
</View>
</View>
<FlatList
@@ -253,4 +263,4 @@ EditorTabs.present = () => {
presentSheet({
component: (ref, close, update) => <EditorTabs close={close} />
});
};
};

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 { hosts, Note } from "@notesnook/core";
import { Note } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
@@ -45,21 +45,30 @@ 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";
import { useAppState } from "../../../../app/hooks/use-app-state";
async function fetchMonographData(noteId: string) {
const monographId = db.monographs.monograph(noteId);
const monograph = monographId
? await db.monographs.get(monographId)
: undefined;
const metadata = monographId
? await db.monographs.metadata(monographId)
: { publishUrl: "", analytics: { totalViews: 0 } };
return {
monograph,
monographId
monographId,
metadata
};
}
@@ -76,26 +85,54 @@ 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 lastMonographDataResult = useRef<Awaited<
ReturnType<typeof db.monographs.metadata>
> | null>(null);
const monographData = useAsync(
async () => {
return fetchMonographData(note?.id);
},
[],
{
onSuccess: (r) => {
lastMonographDataResult.current = r.metadata;
}
}
);
const monograph = monographData.result?.monograph;
customTitle.current = monograph?.title || note.title || "";
const publishUrl = monograph && `${hosts.MONOGRAPH_HOST}/${monograph?.id}`;
const metadata = monographData.result?.metadata;
const publishUrl = metadata?.publishUrl || monograph?.publishUrl || "";
const isPublished = db.monographs.monograph(note?.id);
const appState = useAppState();
const previousAppState = useRef(appState);
const formRef = useRef(
createFormRef({
title: note.title || "",
password: ""
})
);
useEffect(() => {
if (!monographData.result) return;
const title = monograph?.title || note.title || "";
formRef.current.setValue("title", title);
setTimeout(() => {
titleInput.current?.setNativeProps({ text: title });
}, 50);
}, [monographData.result, monograph]);
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 +141,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 +167,9 @@ const PublishNoteSheet = ({
type: "error",
context: "local"
});
} finally {
setPublishLoading(false);
}
setPublishLoading(false);
};
const setPublishLoading = (value: boolean) => {
setPublishing(value);
@@ -157,6 +197,29 @@ const PublishNoteSheet = ({
setPublishLoading(false);
};
const monographMetadata =
monographData.result?.metadata ?? lastMonographDataResult.current;
useEffect(() => {
const prevState = previousAppState.current;
previousAppState.current = appState;
if (
appState === "active" &&
prevState !== "active" &&
monograph?.id &&
!selfDestruct
) {
monographData.execute();
}
}, [
appState,
monograph?.id,
selfDestruct,
isFeatureAvailable?.isAllowed,
monographData
]);
return (
<View
style={{
@@ -247,11 +310,17 @@ const PublishNoteSheet = ({
</TouchableOpacity>
) : null}
<Input
<FormInput
name="title"
formRef={formRef}
fwdRef={titleInput}
onChangeText={(value) => (customTitle.current = value)}
defaultValue={customTitle.current}
multiline
scrollEnabled
containerStyle={{
maxHeight: 100
}}
placeholder={strings.noteTitle()}
validators={[validators.required(strings.titleIsRequired())]}
/>
<TouchableOpacity
@@ -298,13 +367,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
}}
@@ -357,7 +429,10 @@ const PublishNoteSheet = ({
</View>
</TouchableOpacity>
{isFeatureAvailable?.isAllowed ? (
{isFeatureAvailable?.isAllowed &&
!selfDestruct &&
monographMetadata &&
monographMetadata?.analytics?.totalViews > 0 ? (
<View
style={{
flexDirection: "row",
@@ -381,6 +456,9 @@ const PublishNoteSheet = ({
}}
>
<Paragraph size={AppFontSize.sm}>{strings.views()}</Paragraph>
<Paragraph>
{monographMetadata?.analytics?.totalViews || 0}
</Paragraph>
</View>
</View>
</View>

View File

@@ -21,7 +21,7 @@ import { sanitizeFilename } from "@notesnook/common";
import { strings } from "@notesnook/intl";
import Clipboard from "@react-native-clipboard/clipboard";
import React, { createRef } from "react";
import { Platform, View } from "react-native";
import { PermissionsAndroid, Platform, View } from "react-native";
import RNFetchBlob from "react-native-blob-util";
import FileViewer from "react-native-file-viewer";
import * as ScopedStorage from "react-native-scoped-storage";
@@ -45,6 +45,7 @@ import SheetWrapper from "../../ui/sheet";
import { QRCode } from "../../ui/svg/lazy";
import Paragraph from "../../ui/typography/paragraph";
import { DefaultAppStyles } from "../../../utils/styles";
import { CameraRoll } from "@react-native-camera-roll/camera-roll";
class RecoveryKeySheet extends React.Component {
constructor(props) {
@@ -108,22 +109,14 @@ class RecoveryKeySheet extends React.Component {
saveQRCODE = async () => {
this.svg.current?.toDataURL(async (data) => {
try {
let path;
let fileName = "nn_" + this.user.email + "_recovery_key_qrcode";
let fileName =
"nn_" + this.user.email + "_recovery_key_qrcode" + "_" + Date.now();
fileName = sanitizeFilename(fileName, { replacement: "_" });
fileName = fileName + ".png";
if (Platform.OS === "android") {
await ScopedStorage.createDocument(
fileName,
"image/png",
data,
"base64"
);
} else {
path = await filesystem.checkAndCreateDir("/");
await RNFetchBlob.fs.writeFile(path + fileName, data, "base64");
}
const path = RNFetchBlob.fs.dirs.CacheDir + fileName;
await RNFetchBlob.fs.writeFile(path, data, "base64");
await CameraRoll.saveToCameraRoll(`file://` + path);
ToastManager.show({
heading: strings.recoveryKeyQRCodeSaved(),
type: "success",

View File

@@ -104,8 +104,8 @@ const ListBlockItem = ({
{item?.content.length > 200
? item?.content.slice(0, 200) + "..."
: !item.content || item.content.trim() === ""
? strings.linkNoteEmptyBlock()
: item.content}
? strings.linkNoteEmptyBlock()
: item.content}
</Paragraph>
<View
@@ -202,7 +202,7 @@ const ListNoteItem = ({
items: VirtualizedGrouping<Note> | undefined;
onSelect: (item: Note, blockId?: string) => void;
reference: Note;
internalLinks: MutableRefObject<InternalLink<"note">[] | undefined>;
internalLinks: MutableRefObject<InternalLink[] | undefined>;
listType: "linkedNotes" | "referencedIn";
}) => {
const { colors } = useThemeColors();
@@ -231,7 +231,10 @@ const ListNoteItem = ({
internalLinks.current = await db.notes.internalLinks(reference.id);
}
const noteLinks = internalLinks.current.filter(
(link) => link.id === item.id && link.params?.blockId
(link) =>
link.id === item.id &&
link.type === "note" &&
link.params?.blockId
);
if (noteLinks.length) {
@@ -239,7 +242,10 @@ const ListNoteItem = ({
setLinkedBlocks(
blocks.filter((block) =>
noteLinks.find((link) => block.id === link.params?.blockId)
noteLinks.find(
(link) =>
link.type === "note" && block.id === link.params?.blockId
)
)
);
}
@@ -404,7 +410,7 @@ export const ReferencesList = ({ item, close }: ReferencesListProps) => {
const { colors } = useThemeColors();
const [items, setItems] = useState<VirtualizedGrouping<Note>>();
const hasNoRelations = !items || items?.placeholders?.length === 0;
const internalLinks = useRef<InternalLink<"note">[]>([]);
const internalLinks = useRef<InternalLink[]>([]);
useEffect(() => {
db.relations?.[tab === 0 ? "from" : "to"]?.(

View File

@@ -1,173 +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}
groupType={
referenceType === "note"
? "notes"
: referenceType === "tag"
? "tags"
: referenceType === "notebook"
? "notebooks"
: referenceType === "reminder"
? "reminders"
: "notes"
}
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

@@ -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,20 +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,
group: groupType
group: groupType,
groupId,
type
}: {
type: ItemType;
dataType: ItemType;
type?: GroupingByIdKey;
screen?: RouteName;
group: GroupingKey;
hideGroupOptions?: boolean;
groupId?: string;
}) => {
const { colors } = useThemeColors();
const [groupOptions, setGroupOptions] = useState(
db.settings.getGroupOptions(groupType)
getGroupOptions(groupType, groupId, type)
);
const getSortButtonTitle = () => {
@@ -79,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);
};

View File

@@ -493,7 +493,7 @@ const TabBar = (props: SimpleTabBarProps) => {
presentSheet({
component: (
<Sort
type={
dataType={
props.navigationState.index === 1
? "notebook"
: "tag"

View File

@@ -0,0 +1,71 @@
/*
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 Affero 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { Note } from "@notesnook/core";
import { useThemeColors } from "@notesnook/theme";
import React from "react";
import { TextStyle, ViewStyle } from "react-native";
import { AppFontSize, defaultBorderRadius } from "../../../utils/size";
import { DefaultAppStyles } from "../../../utils/styles";
import { Button, ButtonProps } from "../button";
import dayjs from "dayjs";
export const ExpiryDate = ({
note,
color,
style,
textStyle,
iconSize,
short,
...props
}: {
note: Note;
color?: string;
style?: ViewStyle;
textStyle?: TextStyle;
iconSize?: number;
short?: boolean;
} & ButtonProps) => {
const { colors } = useThemeColors();
const expiryValue = note?.expiryDate?.value;
if (!expiryValue) return null;
const formattedDate = dayjs(expiryValue).format("DD MMM YYYY");
return (
<Button
title={formattedDate}
icon="bomb"
fontSize={textStyle?.fontSize || AppFontSize.xs}
iconSize={iconSize || AppFontSize.sm}
type="secondary"
textStyle={{
marginRight: 0,
...textStyle
}}
style={{
height: "auto",
borderRadius: defaultBorderRadius,
borderColor: colors.primary.border,
paddingHorizontal: DefaultAppStyles.GAP_SMALL,
...(style as ViewStyle)
}}
{...props}
/>
);
};

View File

@@ -17,6 +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 { useThemeColors } from "@notesnook/theme";
import React, { RefObject, useEffect, useMemo, useState } from "react";
import {
ColorValue,
@@ -29,12 +30,11 @@ import {
} 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 { AppFontSize, defaultBorderRadius } from "../../../utils/size";
import { DefaultAppStyles } from "../../../utils/styles";
import AppIcon from "../AppIcon";
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>>;
@@ -58,10 +58,12 @@ export interface FormRef {
validateField: (name: string) => string | undefined;
validate: () => boolean;
subscribe: (listener: () => void) => () => void;
subscribeChange: (listener: () => void) => () => void;
}
export function createFormRef(initialValues: FormValues = {}): FormRef {
const listeners = new Set<() => void>();
const changeListeners = new Set<() => void>();
const values: FormValues = { ...initialValues };
const errors: FormErrors = {};
const schema: ValidationSchema = {};
@@ -70,6 +72,10 @@ export function createFormRef(initialValues: FormValues = {}): FormRef {
listeners.forEach((listener) => listener());
};
const notifyChange = () => {
changeListeners.forEach((listener) => listener());
};
return {
values,
errors,
@@ -79,6 +85,7 @@ export function createFormRef(initialValues: FormValues = {}): FormRef {
delete errors[name];
notify();
}
notifyChange();
},
getValue(name) {
return values[name] ?? "";
@@ -100,6 +107,7 @@ export function createFormRef(initialValues: FormValues = {}): FormRef {
clearErrors() {
Object.keys(errors).forEach((key) => delete errors[key]);
notify();
notifyChange();
},
registerField(name, validators = []) {
schema[name] = validators;
@@ -139,6 +147,12 @@ export function createFormRef(initialValues: FormValues = {}): FormRef {
return () => {
listeners.delete(listener);
};
},
subscribeChange(listener) {
changeListeners.add(listener);
return () => {
changeListeners.delete(listener);
};
}
};
}

View File

@@ -48,7 +48,6 @@ export const Notice = ({
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
paddingLeft: 5,
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
flexDirection: "row",
backgroundColor: colors.secondary.background,
@@ -63,6 +62,9 @@ export const Notice = ({
size={isSmall ? AppFontSize.md + 2 : AppFontSize.xxl}
name={type}
color={type === "alert" ? colors.error.icon : colors.primary.accent}
style={{
marginTop: isSmall ? 3 : 5
}}
/>
<Paragraph
style={{

View File

@@ -268,8 +268,8 @@ export const Pressable = ({
const opacity = customOpacity
? customOpacity
: type === "accent"
? 1
: colorOpacity;
? 1
: colorOpacity;
const alpha = customAlpha ? customAlpha : isDark ? 0.03 : -0.03;
const { fontScale } = useWindowDimensions();
const growFactor = 1 + (fontScale - 1) / 8;
@@ -325,7 +325,10 @@ export const Pressable = ({
ref={fwdRef}
disabled={disabled}
hitSlop={hitSlop}
onPress={onPress}
onPress={(e) => {
if (disabled) return;
onPress?.(e);
}}
onLongPress={onLongPress}
style={getStyle}
>

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,
@@ -69,7 +69,11 @@ import { useSelectionStore } from "../stores/use-selection-store";
import { useSettingStore } from "../stores/use-setting-store";
import { useTagStore } from "../stores/use-tag-store";
import { useUserStore } from "../stores/use-user-store";
import { eCloseSheet, eUpdateNoteInEditor } from "../utils/events";
import {
eCloseSheet,
eMenuItemUpdate,
eUpdateNoteInEditor
} from "../utils/events";
import { deleteItems } from "../utils/functions";
import { convertNoteToText } from "../utils/note-to-text";
import { NotesnookModule } from "../utils/notesnook-module";
@@ -108,6 +112,7 @@ export type ActionId =
| "attachments"
| "history"
| "copy-link"
| "copy-id"
| "reminders"
| "lock-unlock"
| "publish"
@@ -122,7 +127,8 @@ export type ActionId =
| "default-homepage"
| "default-tag"
| "launcher-shortcut"
| "expiry-date";
| "expiry-date"
| "spell-check";
export type Action = {
id: ActionId;
@@ -784,6 +790,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();
};
@@ -1009,6 +1021,28 @@ export const useActions = ({
pro: true,
activeColor: "orange"
},
{
id: "spell-check",
title: strings.spellCheck(),
icon: "spellcheck",
isToggle: true,
checked: item.spellcheck,
onPress: async () => {
db.notes.spellcheck(!item.spellcheck, item.id);
const note = await db.notes.note(item.id);
if (note) {
setItem(note);
const tabs = useTabStore.getState().getTabsForNote(note.id);
tabs.forEach((tab) => {
useTabStore.getState().updateTab(tab.id, {
session: {
spellCheckDisabled: !note.spellcheck
}
});
});
}
}
},
{
id: "remove-from-notebook",
title: strings.removeFromNotebook(),
@@ -1028,27 +1062,14 @@ export const useActions = ({
icon: "history",
onPress: openHistory
},
{
id: "copy-link",
title: strings.copyLink(),
icon: "link",
onPress: () => {
Clipboard.setString(createInternalLink("note", item.id));
ToastManager.show({
heading: strings.linkCopied(),
message: createInternalLink("note", item.id),
context: "local",
type: "success"
});
}
},
{
id: "reminders",
title: strings.dataTypesPluralCamelCase.reminder(),
icon: "clock-outline",
onPress: async () => {
close();
RelationsList.present({
reference: item,
item,
referenceType: "reminder",
relationType: "from",
title: strings.dataTypesPluralCamelCase.reminder(),
@@ -1065,26 +1086,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
}
});
},
@@ -1128,8 +1129,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);
}
},
@@ -1192,12 +1194,22 @@ export const useActions = ({
},
{
id: "expiry-date",
title: item.expiryDate ? strings.unsetExpiry() : strings.setExpiry(),
icon: item.expiryDate ? "bomb-off" : "bomb",
title: item.expiryDate?.value
? strings.unsetExpiry()
: strings.setExpiry(),
icon: item.expiryDate?.value ? "bomb-off" : "bomb",
locked: !features?.expiringNotes?.isAllowed,
onPress: async () => {
if (item.expiryDate) {
if (item.expiryDate?.value) {
await db.notes.setExpiryDate(null, item.id);
Navigation.queueRoutesForUpdate();
eSendEvent(eMenuItemUpdate);
ToastManager.show({
message: strings.expiryDateRemoved(),
type: "success",
context: "local"
});
setItem((await db.notes.note(item.id)) as Item);
} else {
if (features && !features?.expiringNotes.isAllowed) {
@@ -1221,6 +1233,14 @@ export const useActions = ({
onConfirm={async (date) => {
close?.();
await db.notes.setExpiryDate(date.getTime(), item.id);
Navigation.queueRoutesForUpdate();
eSendEvent(eMenuItemUpdate);
ToastManager.show({
message: strings.expiryDateSet(),
type: "success",
context: "local"
});
setItem((await db.notes.note(item.id)) as Item);
}}
/>
@@ -1301,5 +1321,45 @@ export const useActions = ({
});
}
if (
item.type === "tag" ||
item.type === "note" ||
item.type === "notebook" ||
item.type === "color"
) {
actions.push({
id: "copy-link",
title: strings.copyLink(),
icon: "link",
onPress: () => {
const link = createInternalLink(item.type, item.id);
Clipboard.setHTML(`<a href="${link}" >${item.title}</a>`, link);
ToastManager.show({
heading: strings.linkCopied(),
message: link,
context: "local",
type: "success"
});
}
});
}
if (item.type === "notebook" || item.type === "tag") {
actions.push({
id: "copy-id",
title: strings.copyId(),
icon: "identifier",
onPress: () => {
Clipboard.setString(item.id);
ToastManager.show({
heading: strings.idCopied(),
message: item.id,
context: "local",
type: "success"
});
}
});
}
return actions;
};

View File

@@ -25,7 +25,9 @@ import {
SYNC_CHECK_IDS,
SubscriptionPlan,
SyncStatusEvent,
User
User,
isInternalLink,
parseInternalLink
} from "@notesnook/core";
import { strings } from "@notesnook/intl";
import notifee from "@notifee/react-native";
@@ -165,6 +167,8 @@ const onAppOpenedFromURL = async (event: {
}) => {
const url = event.url;
const parsedLink = isInternalLink(url) ? parseInternalLink(url) : undefined;
try {
if (url.startsWith("https://app.notesnook.com/account/verified")) {
await onUserEmailVerified();
@@ -173,8 +177,12 @@ const onAppOpenedFromURL = async (event: {
eSendEvent(eOnLoadNote, { newNote: true });
fluidTabsRef.current?.goToPage("editor", false);
return;
} else if (url.startsWith("https://app.notesnook.com/open_note?")) {
const id = new URL(url).searchParams.get("id");
} else if (
parsedLink?.type === "note" ||
url.startsWith("https://app.notesnook.com/open_note?")
) {
const id = parsedLink?.id || new URL(url).searchParams.get("id");
if (id) {
const note = await db.notes.note(id);
if (note) {
@@ -186,13 +194,15 @@ const onAppOpenedFromURL = async (event: {
}
}
} else if (
url.startsWith("https://app.notesnook.com/open_notebook?") &&
(parsedLink?.type === "notebook" ||
url.startsWith("https://app.notesnook.com/open_notebook?")) &&
!event.isInitialUrl
) {
const id = new URL(url).searchParams.get("id");
const id = parsedLink?.id || new URL(url).searchParams.get("id");
if (id) {
const notebook = await db.notebooks.notebook(id);
if (notebook) {
fluidTabsRef.current?.goToPage("home");
Navigation.navigate("Notebook", {
id: notebook.id,
canGoBack: true,
@@ -201,13 +211,15 @@ const onAppOpenedFromURL = async (event: {
}
}
} else if (
url.startsWith("https://app.notesnook.com/open_tag?") &&
(parsedLink?.type === "tag" ||
url.startsWith("https://app.notesnook.com/open_tag?")) &&
!event.isInitialUrl
) {
const id = new URL(url).searchParams.get("id");
const id = parsedLink?.id || new URL(url).searchParams.get("id");
if (id) {
const tag = await db.tags.tag(id);
if (tag) {
fluidTabsRef.current?.goToPage("home");
Navigation.navigate("TaggedNotes", {
type: "tag",
id: tag.id,
@@ -217,13 +229,15 @@ const onAppOpenedFromURL = async (event: {
}
}
} else if (
url.startsWith("https://app.notesnook.com/open_color?") &&
(parsedLink?.type === "color" ||
url.startsWith("https://app.notesnook.com/open_color?")) &&
!event.isInitialUrl
) {
const id = new URL(url).searchParams.get("id");
const id = parsedLink?.id || new URL(url).searchParams.get("id");
if (id) {
const color = await db.colors.color(id);
if (color) {
fluidTabsRef.current?.goToPage("home");
Navigation.navigate("ColoredNotes", {
type: "color",
id: color.id,
@@ -714,6 +728,10 @@ export const useAppEvents = () => {
useEffect(() => {
const subscriptions = [
db.eventManager.subscribe(EVENTS.syncCheckStatus, onCheckSyncStatus),
db.eventManager.subscribe(EVENTS.userFetched, async () => {
const inboxEnabled = await db.user.hasInboxKeys();
useSettingStore.getState().setInboxEnabled(inboxEnabled);
}),
db.eventManager.subscribe(EVENTS.syncAborted, onSyncAborted),
db.eventManager.subscribe(EVENTS.appRefreshRequested, onSyncComplete),
db.eventManager.subscribe(EVENTS.userLoggedOut, onLogout),
@@ -748,6 +766,26 @@ export const useAppEvents = () => {
db.eventManager.subscribe(EVENTS.uploadCanceled, (data) => {
useAttachmentStore.getState().setUploading(data);
}),
db.eventManager.subscribeMulti(
[EVENTS.vaultAutoLocked, EVENTS.vaultLocked],
() => {
setTimeout(() => {
ToastManager.show({
message: strings.vaultLocked(),
type: "info"
});
}, 300);
},
undefined
),
db.eventManager.subscribe(EVENTS.vaultUnlocked, () => {
setTimeout(() => {
ToastManager.show({
message: strings.vaultUnlocked(),
type: "info"
});
}, 300);
}),
EV.subscribe(EVENTS.migrationStarted, (name) => {
if (
name !== "notesnook" ||
@@ -802,7 +840,13 @@ export const useAppEvents = () => {
return () => {
emitterSubscriptions.forEach((sub) => sub?.remove?.());
subscriptions.forEach((sub) => sub?.unsubscribe?.());
subscriptions.forEach((sub) => {
if (Array.isArray(sub)) {
sub.forEach((s) => s.unsubscribe());
} else {
sub?.unsubscribe?.();
}
});
};
}, [onSyncComplete, onUserUpdated]);

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

@@ -30,6 +30,7 @@ import { useSettingStore } from "../stores/use-setting-store";
import { rootNavigatorRef } from "../utils/global-refs";
import Navigation from "../services/navigation";
import { isFeatureAvailable } from "@notesnook/common";
import { isInternalLink, parseInternalLink } from "@notesnook/core";
const RootStack = createNativeStackNavigator();
const AppStack = createNativeStackNavigator();
@@ -62,10 +63,17 @@ const AppNavigation = React.memo(
React.useEffect(() => {
if (!home) {
if (useSettingStore.getState().initialUrl) {
const url = useSettingStore.getState().initialUrl;
if (url?.startsWith("https://app.notesnook.com/open_notebook?")) {
const id = new URL(url).searchParams.get("id");
const url = useSettingStore.getState().initialUrl;
if (url) {
const parsedLink = isInternalLink(url)
? parseInternalLink(url)
: undefined;
if (
parsedLink?.type === "notebook" ||
url?.startsWith("https://app.notesnook.com/open_notebook?")
) {
const id = parsedLink?.id || new URL(url).searchParams.get("id");
if (id) {
setHome({
name: "Notebook",
@@ -75,8 +83,11 @@ const AppNavigation = React.memo(
});
return;
}
} else if (url?.startsWith("https://app.notesnook.com/open_tag?")) {
const id = new URL(url).searchParams.get("id");
} else if (
parsedLink?.type === "tag" ||
url?.startsWith("https://app.notesnook.com/open_tag?")
) {
const id = parsedLink?.id || new URL(url).searchParams.get("id");
if (id) {
setHome({
name: "TaggedNotes",
@@ -87,8 +98,11 @@ const AppNavigation = React.memo(
});
return;
}
} else if (url?.startsWith("https://app.notesnook.com/open_color?")) {
const id = new URL(url).searchParams.get("id");
} else if (
parsedLink?.type === "color" ||
url?.startsWith("https://app.notesnook.com/open_color?")
) {
const id = parsedLink?.id || new URL(url).searchParams.get("id");
if (id) {
setHome({
name: "ColoredNotes",
@@ -279,6 +293,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 +400,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

@@ -95,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"]>(
@@ -282,7 +292,6 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
defaultValue={reminder?.title || referencedItem?.title}
placeholder={strings.remindeMeOf()}
onChangeText={(text) => (title.current = text)}
autoFocus
wrapperStyle={{
marginTop: DefaultAppStyles.GAP_VERTICAL
}}

View File

@@ -64,6 +64,7 @@ import { strings } from "@notesnook/intl";
import { i18n } from "@lingui/core";
import { useVaultStatus } from "../../hooks/use-vault-status";
import { useSettingStore } from "../../stores/use-setting-store";
import { NativeEvents } from "@notesnook/editor-mobile/src/utils/native-events";
const style: ViewStyle = {
height: "100%",
@@ -268,17 +269,22 @@ const useLockedNoteHandler = () => {
const onSubmit = async ({
password,
biometrics: enrollBiometrics
biometrics: enrollBiometrics,
resolverId
}: {
password: string;
biometrics?: boolean;
resolverId?: string;
}) => {
if (!tabRef.current?.session?.noteId || !tabRef.current) return;
if (!password || password.trim().length === 0) {
ToastManager.show({
heading: strings.passwordNotEntered(),
type: "error"
});
if (resolverId) {
editorController.current?.postMessage(NativeEvents.resolve, {
resolverId,
data: { success: false, error: strings.passwordNotEntered() }
});
}
return;
}
@@ -287,6 +293,7 @@ const useLockedNoteHandler = () => {
tabRef.current?.session?.noteId,
password
);
if (enrollBiometrics && note) {
try {
const unlocked = await db.vault.unlock(password);
@@ -298,7 +305,6 @@ const useLockedNoteHandler = () => {
type: "success",
context: "global"
});
const biometry = await BiometricService.isBiometryAvailable();
const fingerprint = await BiometricService.hasInternetCredentials();
useTabStore.setState({
@@ -306,22 +312,24 @@ const useLockedNoteHandler = () => {
biometryEnrolled: !!fingerprint
});
syncTabs();
} catch (e) {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error"
});
}
} catch (e) {}
}
eSendEvent(eOnLoadNote, {
item: note,
refresh: true
});
if (resolverId) {
editorController.current?.postMessage(NativeEvents.resolve, {
resolverId,
data: { success: true }
});
}
eSendEvent(eOnLoadNote, { item: note, refresh: true });
} catch (e) {
ToastManager.show({
heading: strings.passwordIncorrect(),
type: "error"
});
if (resolverId) {
editorController.current?.postMessage(NativeEvents.resolve, {
resolverId,
data: { success: false, error: strings.passwordIncorrect() }
});
}
}
};

View File

@@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
/* eslint-disable no-case-declarations */
/* eslint-disable @typescript-eslint/no-var-requires */
import { isFeatureAvailable, useAreFeaturesAvailable } from "@notesnook/common";
import { ItemReference } from "@notesnook/core";
import { ItemReference, parseInternalLink } from "@notesnook/core";
import type { Attachment } from "@notesnook/editor";
import { EditorEvents } from "@notesnook/editor-mobile/src/utils/editor-events";
import { NativeEvents } from "@notesnook/editor-mobile/src/utils/native-events";
@@ -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(),
@@ -532,7 +532,59 @@ export const useEditorEvents = (
downloadAttachment((editorMessage.value as Attachment)?.hash, true);
break;
}
case EditorEvents.getLinkData: {
const url = (editorMessage.value as any)?.url as string;
const link = parseInternalLink(url);
if (!link) return;
switch (link.type) {
case "note":
case "notebook":
case "tag": {
const table =
link.type === "note"
? "notes"
: link.type === "notebook"
? "notebooks"
: "tags";
const item = await db
.sql()
.selectFrom(table)
.where("id", "=", link.id)
.select("title")
.executeTakeFirst();
editor.postMessage(NativeEvents.resolve, {
resolverId: editorMessage.resolverId,
data: {
type: link.type,
title: item?.title || ""
}
});
break;
}
case "color": {
const color = await db
.sql()
.selectFrom("colors")
.where("id", "=", link.id)
.select(["title", "colorCode"])
.executeTakeFirst();
editor.postMessage(NativeEvents.resolve, {
resolverId: editorMessage.resolverId,
data: {
type: "color",
title: color?.title || "",
metadata: {
colorCode: color?.colorCode || ""
}
}
});
}
}
break;
}
case EditorEvents.getAttachmentData: {
const data = (editorMessage.value as any)?.attachment as Attachment;
@@ -721,7 +773,10 @@ export const useEditorEvents = (
}
case EditorEvents.unlock: {
eSendEvent(eUnlockWithPassword, editorMessage.value);
eSendEvent(eUnlockWithPassword, {
...editorMessage.value,
resolverId: editorMessage.resolverId
});
break;
}

View File

@@ -276,7 +276,8 @@ export const useEditor = (
locked: undefined,
readonly: undefined,
scrollTop: undefined,
selection: undefined
selection: undefined,
spellCheckDisabled: false
}
});
return;
@@ -646,6 +647,7 @@ export const useEditor = (
session.noteLocked = isLockedNote;
session.locked = tabLocked;
session.readonly = item.readonly;
session.spellCheckDisabled = !item.spellcheck;
const tab = useTabStore.getState().getTab(tabId!);
@@ -947,6 +949,7 @@ export const useEditor = (
`saveContent... title: ${!!title}, content: ${!!content}, noteId: ${noteId}`
);
if (
ignoreEdit ||
lock.current ||
(currentLoadingNoteId.current &&
currentLoadingNoteId.current === noteId)
@@ -955,6 +958,7 @@ export const useEditor = (
lock.current: ${lock.current}
currentLoadingNoteId.current: ${currentLoadingNoteId.current}
ignoreEdit: ${ignoreEdit}
`);
if (lock.current) {
setTimeout(() => {

View File

@@ -86,6 +86,7 @@ export type TabSessionItem = {
noteLocked?: boolean;
locked?: boolean;
readonly?: boolean;
spellCheckDisabled: boolean;
};
const TabSessionStorageKV = new MMKVLoader()
@@ -187,6 +188,7 @@ export type TabStore = {
focusEmptyTab: () => void;
getCurrentNoteId: () => string | undefined;
getTab: (tabId: string) => TabItem | undefined;
clearAllTabs: () => void;
newTabSession: (
id: string,
options?: Omit<Partial<TabSessionItem>, "id">
@@ -208,7 +210,8 @@ const DEFAULT_TABS = {
{
id: "679da59a3924d4bd56d16d3f",
session: {
id: "679da5a5667a16db2353a062"
id: "679da5a5667a16db2353a062",
spellCheckDisabled: false
}
}
],
@@ -255,7 +258,7 @@ export const useTabStore = create<TabStore, any>(
const sessionId =
oldSessionId &&
tabSessionHistory.currentSessionId(tabId) === oldSessionId
tabSessionHistory.currentSessionId(tabId) === oldSessionId
? oldSessionId
: tabSessionHistory.add(tabId, oldSessionId);
@@ -386,7 +389,7 @@ export const useTabStore = create<TabStore, any>(
focusPreviewTab: (
noteId: string,
options: Omit<Partial<TabItem>, "id" | "noteId">
) => {},
) => { },
removeTab: (id: string) => {
const index = get().tabs.findIndex((t) => t.id === id);
@@ -482,6 +485,25 @@ export const useTabStore = create<TabStore, any>(
},
getTab: (tabId) => {
return get().tabs.find((t) => t.id === tabId);
},
clearAllTabs: () => {
const tabs = get().tabs;
tabs.forEach((tab) => {
const tabSessions = tabSessionHistory.getTabHistory(tab.id);
tabSessions.back.forEach((id) => TabSessionStorage.remove(id));
tabSessions.forward.forEach((id) => TabSessionStorage.remove(id));
tabSessionHistory.clearStackForTab(tab.id);
});
const id = getId();
set({
tabs: [{ id: id }],
currentTab: id
});
history.history = [id];
get().newTabSession(id);
get().focusTab(id);
syncTabs();
}
}),
{

View File

@@ -142,7 +142,7 @@ export function isContentInvalid(content: string | undefined) {
export async function openInternalLink(url: string) {
const data = parseInternalLink(url);
if (!data?.id) return false;
if (!data?.id || data.type !== "note") return false;
if (
data.id ===
useTabStore.getState().getNoteIdForTab(useTabStore.getState().currentTab!)

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();
@@ -190,6 +193,7 @@ const NotebookScreen = ({ route, navigation }: NavigationProps<"Notebook">) => {
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);
@@ -222,6 +221,7 @@ const NotesPage = ({
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

@@ -36,9 +36,11 @@ import {
WeekFormatPicker,
FontPicker,
HomePicker,
ImageCompressionPicker,
SidebarTabPicker,
TimeFormatPicker,
TrashIntervalPicker
TrashIntervalPicker,
VaultLockTimerPicker
} from "./picker/pickers";
import { RestoreBackup } from "./restore-backup";
import { ServersConfiguration } from "./server-config";
@@ -46,6 +48,12 @@ import SoundPicker from "./sound-picker";
import ThemeSelector from "./theme-selector";
import { TitleFormat } from "./title-format";
import { NotesnookCircle } from "./notesnook-circle";
import {
ManageInboxKeys,
InboxKeysList,
SetupInboxKeys
} from "./manage-inbox-keys";
import { FailedInboxItems } from "./failed-inbox-items";
export const components: { [name: string]: ReactElement } = {
homeselector: <HomePicker />,
@@ -61,8 +69,10 @@ export const components: { [name: string]: ReactElement } = {
"time-format-selector": <TimeFormatPicker />,
"day-format-selector": <DayFormatPicker />,
"week-format-selector": <WeekFormatPicker />,
"image-compression-picker": <ImageCompressionPicker />,
"theme-selector": <ThemeSelector />,
"applock-timer": <ApplockTimerPicker />,
"vault-lock-timer": <VaultLockTimerPicker />,
autobackupsattachments: <BackupWithAttachmentsReminderPicker />,
backuprestore: <RestoreBackup />,
"server-config": <ServersConfiguration />,
@@ -75,5 +85,9 @@ export const components: { [name: string]: ReactElement } = {
"sidebar-tab-selector": <SidebarTabPicker />,
"change-password": <ChangePassword />,
"change-email": <ChangeEmail />,
"notesnook-circle": <NotesnookCircle />
"notesnook-circle": <NotesnookCircle />,
"manage-inbox-keys": <ManageInboxKeys />,
"inbox-keys": <InboxKeysList />,
"failed-inbox-items": <FailedInboxItems />,
"setup-inbox-keys": <SetupInboxKeys />
};

View File

@@ -0,0 +1,404 @@
/*
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 { getFormattedDate, usePromise } from "@notesnook/common";
import { InboxItemsHistoryErrorContext } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
import React, { useEffect, useState } from "react";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { db } from "../../common/database";
import { presentDialog } from "../../components/dialog/functions";
import { Button } from "../../components/ui/button";
import { IconButton } from "../../components/ui/icon-button";
import Paragraph from "../../components/ui/typography/paragraph";
import { ToastManager } from "../../services/event-manager";
import { AppFontSize, defaultBorderRadius } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { Header } from "../../components/header";
type FailedInboxItem = {
id: string;
dateSynced: number;
errorContext?: string;
};
function parseErrorContext(
raw: string | undefined
): InboxItemsHistoryErrorContext | null {
if (!raw) return null;
try {
return JSON.parse(raw) as InboxItemsHistoryErrorContext;
} catch {
return null;
}
}
function ErrorBadge({
message
}: {
message: InboxItemsHistoryErrorContext["message"] | undefined;
}) {
const { colors } = useThemeColors();
if (!message) {
return (
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
N/A
</Paragraph>
);
}
const palette =
message === "Invalid JSON"
? { background: "rgba(255, 152, 0, 0.15)", paragraph: "#e65100" }
: message === "Validation failed"
? { background: "rgba(255, 193, 7, 0.15)", paragraph: "#8a6000" }
: colors.error;
return (
<View
style={{
alignSelf: "flex-start",
backgroundColor: palette.background,
borderRadius: defaultBorderRadius,
paddingVertical: 4,
paddingHorizontal: 8
}}
>
<Paragraph
size={AppFontSize.xxs}
color={palette.paragraph}
style={{ fontWeight: "700" }}
>
{message}
</Paragraph>
</View>
);
}
function DetailsBlock({ value }: { value: string }) {
const { colors } = useThemeColors();
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const timeout = setTimeout(() => setCopied(false), 1000);
return () => clearTimeout(timeout);
}, [copied]);
return (
<View
style={{
borderWidth: 1,
borderColor: colors.secondary.border,
borderRadius: defaultBorderRadius,
backgroundColor: colors.secondary.background,
overflow: "hidden"
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "flex-end",
borderBottomWidth: 1,
borderBottomColor: colors.secondary.border
}}
>
<IconButton
name={copied ? "check" : "content-copy"}
color={colors.primary.icon}
size={AppFontSize.lg}
onPress={() => {
try {
Clipboard.setString(value);
setCopied(true);
ToastManager.show({
message: strings.copied(),
type: "success"
});
} catch {
ToastManager.show({
message: strings.failedToCopyToClipboard(),
type: "error"
});
}
}}
/>
</View>
<ScrollView
style={{ maxHeight: 120 }}
contentContainerStyle={{
padding: DefaultAppStyles.GAP_SMALL
}}
>
<Paragraph
size={AppFontSize.xxs}
style={{
fontFamily: "monospace"
}}
>
{value}
</Paragraph>
</ScrollView>
</View>
);
}
export const FailedInboxItems = () => {
const { colors } = useThemeColors();
const result = usePromise(() => db.inboxItemsHistory.failed.items());
async function deleteItem(id: string) {
await db.inboxItemsHistory.delete([id]);
ToastManager.show({
message: strings.itemDeleted(),
type: "success"
});
if (result.status !== "pending") {
result.refresh();
}
}
const items = (
result.status === "fulfilled" ? result.value : []
) as FailedInboxItem[];
return (
<View
style={{
flex: 1
}}
>
<Header
renderedInRoute="Settings"
title={strings.failedInboxItems()}
canGoBack={true}
id="Settings"
rightButton={
items?.length > 0
? {
name: "delete",
color: colors.primary.icon,
onPress: async () => {
presentDialog({
title: strings.deleteAll(),
paragraph: strings.deleteAllFailedItemsDesc(),
positiveText: strings.delete(),
positiveType: "errorShade",
positivePress: async () => {
if (result.status !== "pending") {
await db.inboxItemsHistory.deleteFailed();
result.refresh();
}
return true;
}
});
}
}
: undefined
}
/>
{result.status === "pending" ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: DefaultAppStyles.GAP_VERTICAL
}}
>
<ActivityIndicator size="small" color={colors.primary.accent} />
<Paragraph color={colors.secondary.paragraph}>
{strings.loading()}
</Paragraph>
</View>
) : null}
{result.status === "rejected" ? (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
gap: DefaultAppStyles.GAP_VERTICAL,
paddingHorizontal: DefaultAppStyles.GAP
}}
>
<Paragraph color={colors.error.paragraph}>
{strings.failed()}
</Paragraph>
<Button
title={strings.retry()}
type="accent"
onPress={result.refresh}
/>
</View>
) : null}
{items.length === 0 ? (
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
paddingTop: DefaultAppStyles.GAP_VERTICAL,
flex: 1,
justifyContent: "center"
}}
>
<View
style={{
padding: DefaultAppStyles.GAP,
borderRadius: defaultBorderRadius,
alignItems: "center"
}}
>
<Paragraph color={colors.secondary.paragraph}>
{strings.noFailedInboxItems()}
</Paragraph>
</View>
</View>
) : null}
{result.status === "fulfilled" && items.length > 0 ? (
<ScrollView
contentContainerStyle={{
gap: DefaultAppStyles.GAP_VERTICAL,
paddingHorizontal: DefaultAppStyles.GAP,
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
paddingBottom: 50
}}
>
{items.map((item) => {
const context = parseErrorContext(item.errorContext);
const { message, description, ...rest } =
context ??
({} as Partial<InboxItemsHistoryErrorContext> &
Record<string, unknown>);
const details =
Object.keys(rest).length > 0
? JSON.stringify(rest, null, 2)
: undefined;
return (
<View
key={item.id}
style={{
borderWidth: 1,
borderColor: colors.secondary.border,
borderRadius: defaultBorderRadius,
backgroundColor: colors.primary.background,
padding: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL
}}
>
<View
style={{
flexDirection: "row",
justifyContent: "space-between"
}}
>
<Paragraph
size={AppFontSize.xs}
color={colors.secondary.paragraph}
>
{getFormattedDate(item.dateSynced, "date-time")}
</Paragraph>
<ErrorBadge message={message} />
</View>
<View
style={{
flexDirection: "row",
gap: DefaultAppStyles.GAP_SMALL,
alignItems: "flex-start"
}}
>
<View
style={{
backgroundColor: colors.error.background,
padding: 3,
paddingHorizontal: 6,
borderRadius: 4,
alignItems: "center",
flexWrap: "wrap"
}}
>
<Paragraph color={colors.error.paragraph}>Error</Paragraph>
</View>
<Paragraph
style={{
flexShrink: 1
}}
>
{(description as string) || "N/A"}
</Paragraph>
</View>
{details ? (
<DetailsBlock value={details} />
) : (
<Paragraph
size={AppFontSize.xs}
color={colors.secondary.paragraph}
>
N/A
</Paragraph>
)}
<View style={{ alignItems: "flex-end" }}>
<Button
title={strings.delete()}
type="error"
style={{
width: "100%"
}}
onPress={() => {
presentDialog({
title: strings.delete(),
paragraph: strings.areYouSure(),
positiveText: strings.delete(),
positiveType: "error",
negativeText: strings.cancel(),
positivePress: async () => {
try {
await deleteItem(item.id);
return true;
} catch (error) {
ToastManager.error(error as Error);
return false;
}
}
});
}}
/>
</View>
</View>
);
})}
</ScrollView>
) : null}
</View>
);
};

View File

@@ -0,0 +1,719 @@
/*
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 { getFormattedDate, usePromise } from "@notesnook/common";
import { InboxApiKey } from "@notesnook/core";
import { SerializedKeyPair } from "@notesnook/crypto";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import Clipboard from "@react-native-clipboard/clipboard";
import React, { useEffect, useRef, useState } from "react";
import { ActivityIndicator, ScrollView, View } from "react-native";
import { DatabaseLogger, db } from "../../common/database";
import { Storage } from "../../common/database/storage";
import { presentDialog } from "../../components/dialog/functions";
import AddApiKeySheet from "../../components/sheets/add-api-key";
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 { Notice } from "../../components/ui/notice";
import Heading from "../../components/ui/typography/heading";
import Paragraph from "../../components/ui/typography/paragraph";
import { ToastManager } from "../../services/event-manager";
import Navigation from "../../services/navigation";
import { useSettingStore } from "../../stores/use-setting-store";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import AppIcon from "../../components/ui/AppIcon";
export const SetupInboxKeys = () => {
const [mode, setMode] = useState<"choose" | "edit">("choose");
const [error, setError] = useState("");
const { colors } = useThemeColors();
const [isLoading, setIsLoading] = useState(false);
const formRef = useRef(
createFormRef({
publicKey: "",
privateKey: ""
})
);
const handleAutoGenerate = async () => {
try {
setIsLoading(true);
await db.user.getInboxKeys();
ToastManager.show({
message: strings.inboxKeysSaved(),
type: "success"
});
useSettingStore.setState({
inboxEnabled: true
});
Navigation.goBack();
} catch (error) {
DatabaseLogger.error(error as Error);
ToastManager.error(error as Error);
} finally {
setIsLoading(false);
}
};
const handleSave = async () => {
try {
if (!formRef.current.validate()) return;
const keysEdited: SerializedKeyPair = {
publicKey: formRef.current.getValue("publicKey"),
privateKey: formRef.current.getValue("privateKey")
};
const result = await Storage.validatePGPKeyPair(keysEdited);
if (!result.isValid) {
setError(result.message);
return;
}
await db.user?.saveInboxKeys(keysEdited);
useSettingStore.setState({
inboxEnabled: true
});
Navigation.goBack();
ToastManager.show({
message: strings.inboxKeysSaved(),
type: "success"
});
} catch (e) {
DatabaseLogger.error(e);
ToastManager.error(e as Error);
}
};
return (
<View>
{mode === "choose" ? (
<>
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL
}}
>
<Paragraph>{strings.setupInboxPgpKeysDescription()}</Paragraph>
<View style={{ gap: DefaultAppStyles.GAP_VERTICAL }}>
<Button
title={strings.autoGenerateKeys()}
type="accent"
width="100%"
onPress={handleAutoGenerate}
disabled={isLoading}
/>
<Button
title={strings.provideOwnKeys()}
type="secondary"
width="100%"
onPress={() => setMode("edit")}
disabled={isLoading}
/>
</View>
</View>
</>
) : (
<ScrollView
contentContainerStyle={{
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL,
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<Paragraph>{strings.publicKey()}</Paragraph>
<FormInput
name="publicKey"
formRef={formRef}
numberOfLines={5}
validators={[validators.required(strings.publicKeyRequired())]}
placeholder={strings.enterPgpPublicKey()}
multiline
containerStyle={{ minHeight: 150, alignItems: "flex-start" }}
inputStyle={{ minHeight: 150, textAlignVertical: "top" }}
wrapperStyle={{ minHeight: 150 }}
/>
<Paragraph>{strings.privateKey()}</Paragraph>
<FormInput
name="privateKey"
formRef={formRef}
numberOfLines={5}
placeholder={strings.enterPgpPrivateKey()}
validators={[validators.required(strings.privateKeyRequired())]}
multiline
containerStyle={{ minHeight: 150, alignItems: "flex-start" }}
inputStyle={{ minHeight: 150, textAlignVertical: "top" }}
wrapperStyle={{ minHeight: 150 }}
/>
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center"
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error}
</Paragraph>
) : null}
<Button
title={strings.save()}
type="accent"
width={"100%"}
onPress={handleSave}
/>
</ScrollView>
)}
</View>
);
};
const ManageInboxKeys = () => {
const { colors } = useThemeColors();
const keys = usePromise(() => db.user.getInboxKeys());
const inboxKeys = keys.status === "fulfilled" ? keys.value : undefined;
const formRef = useRef(
createFormRef({
publicKey: "",
privateKey: ""
})
);
const [formVersion, setFormVersion] = useState(0);
const [hasChanged, setHasChanged] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!inboxKeys) return;
formRef.current.setValue("publicKey", inboxKeys.publicKey || "");
formRef.current.setValue("privateKey", inboxKeys.privateKey || "");
setFormVersion((prev) => prev + 1);
}, [inboxKeys]);
useEffect(() => {
const unsub = formRef.current?.subscribeChange(() => {
const values = formRef.current?.getValues();
if (
inboxKeys?.publicKey !== values.publicKey ||
inboxKeys.privateKey !== values.privateKey
) {
setHasChanged(true);
} else {
setHasChanged(false);
}
setError("");
});
return unsub;
}, [inboxKeys?.privateKey, inboxKeys?.publicKey]);
return (
<ScrollView
contentContainerStyle={{
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL,
marginTop: DefaultAppStyles.GAP_VERTICAL
}}
>
<Notice type="alert" text={strings.changingInboxPgpKeysNotice()} />
<Paragraph>{strings.publicKey()}</Paragraph>
<FormInput
key={`public-key-${formVersion}`}
name="publicKey"
formRef={formRef}
validators={[validators.required(strings.publicKeyRequired())]}
multiline
containerStyle={{ minHeight: 150, alignItems: "flex-start" }}
inputStyle={{ height: 140, textAlignVertical: "top" }}
wrapperStyle={{ minHeight: 150 }}
/>
<Paragraph>{strings.privateKey()}</Paragraph>
<FormInput
key={`private-key-${formVersion}`}
name="privateKey"
formRef={formRef}
validators={[validators.required(strings.privateKeyRequired())]}
multiline
containerStyle={{ minHeight: 150, alignItems: "flex-start" }}
inputStyle={{ height: 140, textAlignVertical: "top" }}
wrapperStyle={{ minHeight: 150 }}
/>
{error ? (
<Paragraph
numberOfLines={4}
onPress={() => {}}
color={colors.error.accent}
style={{
textAlign: "center"
}}
>
<AppIcon
color={colors.error.accent}
name="alert-circle-outline"
size={AppFontSize.sm - 1}
/>{" "}
{error}
</Paragraph>
) : null}
<Button
title={strings.save()}
disabled={!hasChanged}
type="accent"
width={"100%"}
onPress={async () => {
try {
const keysEdited: SerializedKeyPair = {
publicKey: formRef.current.getValue("publicKey"),
privateKey: formRef.current.getValue("privateKey")
};
if (!keysEdited.privateKey || !keysEdited.publicKey) {
if (!formRef.current.validate()) return;
}
const result = await Storage.validatePGPKeyPair(keysEdited);
if (!result.isValid) {
setError(result.message);
return;
}
presentDialog({
title: strings.areYouSure(),
paragraph: strings.changingInboxPgpKeysNotice(),
positiveText: strings.yes(),
negativeText: strings.no(),
positivePress: async () => {
try {
await db.user?.saveInboxKeys(keysEdited);
ToastManager.show({
message: strings.inboxKeysSaved(),
type: "success"
});
Navigation.goBack();
return true;
} catch (e) {
ToastManager.error(e as Error);
DatabaseLogger.error(e);
return false;
}
}
});
} catch (e) {
DatabaseLogger.error(e);
ToastManager.error(e as Error);
}
}}
/>
</ScrollView>
);
};
const InboxKeysList = () => {
const inboxEnabled = useSettingStore((state) => state.inboxEnabled);
const apiKeysPromise = usePromise(
() => db.inboxApiKeys.get(),
[inboxEnabled]
);
const { colors } = useThemeColors();
if (apiKeysPromise.status === "pending") {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL,
width: "100%"
}}
>
<ActivityIndicator size="small" color={colors.primary.accent} />
<Paragraph color={colors.secondary.paragraph}>
{strings.loadingApiKeys()}
</Paragraph>
</View>
);
}
if (apiKeysPromise.status === "rejected") {
DatabaseLogger.log(apiKeysPromise.reason);
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: DefaultAppStyles.GAP,
gap: DefaultAppStyles.GAP_VERTICAL,
width: "100%"
}}
>
<Paragraph color={colors.error.paragraph}>
{strings.failedToLoadApiKeys()}
</Paragraph>
<Button
title={strings.retry()}
type="accent"
onPress={() => apiKeysPromise.refresh()}
/>
</View>
);
}
const apiKeys = apiKeysPromise.value || [];
return (
<ScrollView
contentContainerStyle={{
gap: DefaultAppStyles.GAP_VERTICAL,
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
width: "100%",
paddingHorizontal: DefaultAppStyles.GAP,
paddingBottom: 50,
minHeight: "100%"
}}
>
{apiKeys.length === 0 ? (
<View
style={{
padding: DefaultAppStyles.GAP * 2,
alignItems: "center",
gap: DefaultAppStyles.GAP_VERTICAL,
flex: 1,
justifyContent: "center"
}}
>
<Paragraph color={colors.secondary.paragraph}>
{strings.createFirstApiKey()}
</Paragraph>
<Button
title={strings.createKey()}
type="accent"
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
width: "100%"
}}
onPress={() => {
if (apiKeys.length >= 10) {
presentDialog({
title: strings.apiKeysLimitReached(),
paragraph: strings.apiKeysLimitReachedMessage(),
positiveText: strings.ok()
});
} else {
AddApiKeySheet.present(() => apiKeysPromise.refresh());
}
}}
/>
</View>
) : (
<View style={{ gap: 0 }}>
{apiKeys.map((key, i) => (
<ApiKeyItem
key={key.key}
apiKey={key}
onRevoke={() => apiKeysPromise.refresh()}
isAtEnd={i === apiKeys.length - 1}
/>
))}
<Button
title={strings.createKey()}
type="accent"
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL_SMALL,
width: "100%"
}}
onPress={() => {
if (apiKeys.length >= 10) {
presentDialog({
title: strings.apiKeysLimitReached(),
paragraph: strings.apiKeysLimitReachedMessage(),
positiveText: strings.ok()
});
} else {
AddApiKeySheet.present(() => apiKeysPromise.refresh());
}
}}
/>
</View>
)}
</ScrollView>
);
};
const VIEW_KEY_TIMEOUT = 15;
type ApiKeyItemProps = {
apiKey: InboxApiKey;
onRevoke: () => void;
isAtEnd: boolean;
};
function ApiKeyItem({ apiKey, onRevoke, isAtEnd }: ApiKeyItemProps) {
const { colors } = useThemeColors();
const [copied, setCopied] = useState(false);
const [viewing, setViewing] = useState(false);
const [isRevoking, setIsRevoking] = useState(false);
const [secondsLeft, setSecondsLeft] = useState(VIEW_KEY_TIMEOUT);
async function viewKey() {
presentDialog({
title: strings.authenticateToViewApiKey(),
paragraph: strings.enterPasswordToViewApiKey(),
positiveText: strings.authenticate(),
negativeText: strings.cancel(),
input: true,
secureTextEntry: true,
inputPlaceholder: strings.accountPassword(),
positivePress: async (value) => {
try {
const verified = await db.user.verifyPassword(value);
if (!verified) {
ToastManager.show({
message: strings.invalidPassword(),
type: "error"
});
return false;
}
setViewing(true);
return true;
} catch (error) {
ToastManager.error(error as Error);
return false;
}
}
});
}
async function copyToClipboard() {
if (!viewing) return;
try {
Clipboard.setString(apiKey.key);
setCopied(true);
ToastManager.show({
message: strings.apiKeyCopiedToClipboard(),
type: "success"
});
} catch (error) {
ToastManager.show({
message: strings.failedToCopyToClipboard(),
type: "error"
});
}
}
useEffect(() => {
if (copied) {
const timer = setTimeout(() => {
setCopied(false);
}, 1000);
return () => clearTimeout(timer);
}
}, [copied]);
useEffect(() => {
if (viewing) {
setSecondsLeft(VIEW_KEY_TIMEOUT);
const interval = setInterval(() => {
setSecondsLeft((prev) => {
if (prev <= 1) {
setViewing(false);
return VIEW_KEY_TIMEOUT;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}
}, [viewing]);
const isApiKeyExpired =
apiKey.expiryDate !== -1 && Date.now() > apiKey.expiryDate;
return (
<View
style={{
paddingVertical: DefaultAppStyles.GAP_VERTICAL,
borderBottomWidth: isAtEnd ? 0 : 1,
borderBottomColor: colors.secondary.border
}}
>
<View
style={{
flexDirection: "column",
gap: DefaultAppStyles.GAP_VERTICAL
}}
>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: DefaultAppStyles.GAP_SMALL
}}
>
<Heading size={AppFontSize.md}>{apiKey.name}</Heading>
{isApiKeyExpired && (
<View
style={{
paddingVertical: 4,
paddingHorizontal: 8,
backgroundColor: colors.error.background,
borderRadius: 5
}}
>
<Paragraph
color={colors.static.red}
size={AppFontSize.xxs}
style={{ fontWeight: "bold" }}
>
EXPIRED
</Paragraph>
</View>
)}
</View>
<View style={{ gap: 4 }}>
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
{apiKey.lastUsedAt
? `${strings.lastUsedOn()} ${getFormattedDate(apiKey.lastUsedAt, "date-time")}`
: strings.neverUsed()}
</Paragraph>
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
{strings.createdOn()}{" "}
{getFormattedDate(apiKey.dateCreated, "date-time")}
</Paragraph>
<Paragraph size={AppFontSize.xs} color={colors.secondary.paragraph}>
{apiKey.expiryDate === -1
? strings.neverExpires()
: `${isApiKeyExpired ? strings.expired() : strings.expiresOn()} ${getFormattedDate(apiKey.expiryDate, "date-time")}`}
</Paragraph>
</View>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: DefaultAppStyles.GAP_SMALL
}}
>
<Input
editable={false}
value={
viewing
? apiKey.key
: `${apiKey.key.slice(0, 10)}${"*".repeat(
apiKey.key.length - 10
)}`
}
style={{
flex: 1,
fontFamily: "monospace",
fontSize: AppFontSize.xs
}}
wrapperStyle={{
flex: 1
}}
/>
{!viewing && (
<IconButton
name="eye-off-outline"
color={colors.primary.icon}
onPress={() => viewKey()}
/>
)}
{viewing && (
<>
<Paragraph
style={{
fontFamily: "monospace",
minWidth: 35,
textAlign: "center"
}}
color={colors.primary.accent}
>
{secondsLeft}s
</Paragraph>
<IconButton
name={copied ? "check" : "content-copy"}
color={colors.primary.icon}
onPress={() => copyToClipboard()}
/>
</>
)}
<IconButton
name="delete-outline"
color={colors.error.icon}
disabled={isRevoking}
onPress={async () => {
presentDialog({
title: strings.revokeInboxApiKey(apiKey.name),
paragraph: strings.revokeApiKeyConfirmation(apiKey.name),
positiveText: strings.revoke(),
negativeText: strings.cancel(),
positiveType: "error",
positivePress: async () => {
try {
setIsRevoking(true);
await db.inboxApiKeys.revoke(apiKey.key);
onRevoke();
ToastManager.show({
message: strings.apiKeyRevoked(),
type: "success"
});
return true;
} catch (error) {
ToastManager.show({
message: strings.failedToRevokeApiKey(),
type: "error"
});
return false;
} finally {
setIsRevoking(false);
}
}
});
}}
/>
</View>
</View>
</View>
);
}
export { InboxKeysList, ManageInboxKeys };

View File

@@ -30,11 +30,11 @@ import { AppFontSize, defaultBorderRadius } from "../../../utils/size";
import { DefaultAppStyles } from "../../../utils/styles";
import { verifyUser } from "../functions";
interface PickerOptions<T> {
getValue: () => T;
interface PickerOptions<T, B = any> {
getValue: () => B;
updateValue: (item: T) => Promise<void>;
formatValue: (item: T) => any;
compareValue: (current: T, item: T) => boolean;
compareValue: (current: B, item: T) => boolean;
getItemKey: (item: T) => string;
options: T[];
isFeatureAvailable: () => Promise<boolean>;
@@ -168,7 +168,7 @@ export function SettingsPicker<T>({
);
}
export function createSettingsPicker<T>(props: PickerOptions<T>) {
export function createSettingsPicker<T, B>(props: PickerOptions<T, B>) {
const Selector = () => {
return <SettingsPicker {...props} />;
};

View File

@@ -17,14 +17,21 @@ 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 { DATE_FORMATS, TIME_FORMATS } from "@notesnook/core";
import {
DATE_FORMATS,
DayFormat,
TIME_FORMATS,
TimeFormat,
TrashCleanupInterval,
WeekFormat
} from "@notesnook/core";
import { getFontById, getFonts } from "@notesnook/editor/dist/cjs/utils/font";
import dayjs from "dayjs";
import { createSettingsPicker } from ".";
import { db } from "../../../common/database";
import { ToastManager } from "../../../services/event-manager";
import SettingsService from "../../../services/settings";
import { useSettingStore } from "../../../stores/use-setting-store";
import { Settings, useSettingStore } from "../../../stores/use-setting-store";
import { useUserStore } from "../../../stores/use-user-store";
import { MenuItemsList } from "../../../utils/menu-items";
import { verifyUserWithApplock } from "../functions";
@@ -44,26 +51,29 @@ const WeekFormatNames = {
Mon: "Monday"
};
export const FontPicker = createSettingsPicker({
export const FontPicker = createSettingsPicker<
ReturnType<typeof getFonts>[0],
Settings["defaultFontFamily"]
>({
getValue: () => useSettingStore.getState().settings.defaultFontFamily,
updateValue: (item) => {
updateValue: async (item) => {
SettingsService.set({
defaultFontFamily: item.id
});
},
formatValue: (item) => {
return getFontById(typeof item === "object" ? item.id : item).title;
return getFontById(typeof item === "object" ? item.id : item)?.title;
},
getItemKey: (item) => item.id,
options: getFonts(),
compareValue: (current, item) => current === item.id,
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const HomePicker = createSettingsPicker({
getValue: () => useSettingStore.getState().settings.homepage,
updateValue: (item) => {
updateValue: async (item) => {
SettingsService.set({ homepage: item.title });
ToastManager.show({
heading: strings.homePageChangedTo(item.title),
@@ -72,18 +82,22 @@ export const HomePicker = createSettingsPicker({
});
},
formatValue: (item) => {
return strings.routes[typeof item === "object" ? item.title : item]?.();
return strings.routes[
(typeof item === "object"
? item.title
: item) as keyof typeof strings.routes
]?.();
},
getItemKey: (item) => item.title,
options: MenuItemsList.slice(0, MenuItemsList.length - 1),
compareValue: (current, item) => current === item.title,
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const SidebarTabPicker = createSettingsPicker({
getValue: () => useSettingStore.getState().settings.defaultSidebarTab,
updateValue: (item) => {
updateValue: async (item) => {
SettingsService.set({ defaultSidebarTab: item });
},
formatValue: (item) => {
@@ -94,7 +108,7 @@ export const SidebarTabPicker = createSettingsPicker({
];
return SidebarTabs[item];
},
getItemKey: (item) => item,
getItemKey: (item) => `side-bar-tab-picker-${item}`,
options: [0, 1, 2],
compareValue: (current, item) => current === item,
isFeatureAvailable: async () => {
@@ -111,25 +125,25 @@ export const SidebarTabPicker = createSettingsPicker({
}
return result.isAllowed;
},
isOptionAvailable: () => true
isOptionAvailable: async () => true
});
export const TrashIntervalPicker = createSettingsPicker({
getValue: () => db.settings.getTrashCleanupInterval(),
updateValue: (item) => {
updateValue: async (item) => {
db.settings.setTrashCleanupInterval(item);
},
formatValue: (item) => {
return item === -1
? strings.never()
: item === 1
? strings.reminderRecurringMode.day()
: strings.days(item);
? strings.reminderRecurringMode.day()
: strings.days(item);
},
getItemKey: (item) => item.toString(),
options: [-1, 1, 7, 30, 365],
options: [-1, 1, 7, 30, 365] as TrashCleanupInterval[],
compareValue: (current, item) => current === item,
isFeatureAvailable: () => true,
isFeatureAvailable: async () => true,
isOptionAvailable: async () => {
const disableTrashFeature = await isFeatureAvailable("disableTrashCleanup");
if (!disableTrashFeature.isAllowed) {
@@ -148,7 +162,7 @@ export const TrashIntervalPicker = createSettingsPicker({
export const DateFormatPicker = createSettingsPicker({
getValue: () => db.settings.getDateFormat(),
updateValue: (item) => {
updateValue: async (item) => {
db.settings.setDateFormat(item);
useSettingStore.setState({
dateFormat: item
@@ -160,13 +174,13 @@ export const DateFormatPicker = createSettingsPicker({
getItemKey: (item) => item,
options: DATE_FORMATS,
compareValue: (current, item) => current === item,
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const DayFormatPicker = createSettingsPicker({
getValue: () => db.settings.getDayFormat(),
updateValue: (item) => {
updateValue: async (item) => {
db.settings.setDayFormat(item);
useSettingStore.setState({
dayFormat: item
@@ -176,15 +190,15 @@ export const DayFormatPicker = createSettingsPicker({
return `${strings.dayFormat()} (${dayjs().format(DayFormatFormats[item])})`;
},
getItemKey: (item) => item,
options: DAY_FORMATS,
options: DAY_FORMATS as DayFormat[],
compareValue: (current, item) => current === item,
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const WeekFormatPicker = createSettingsPicker({
getValue: () => db.settings.getWeekFormat(),
updateValue: (item) => {
updateValue: async (item) => {
db.settings.setWeekFormat(item);
useSettingStore.setState({
weekFormat: item
@@ -194,10 +208,10 @@ export const WeekFormatPicker = createSettingsPicker({
return `${WeekFormatNames[item]}`;
},
getItemKey: (item) => item,
options: WEEK_FORMATS,
options: WEEK_FORMATS as WeekFormat[],
compareValue: (current, item) => current === item,
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
const TimeFormats = {
@@ -207,7 +221,7 @@ const TimeFormats = {
export const TimeFormatPicker = createSettingsPicker({
getValue: () => db.settings.getTimeFormat(),
updateValue: (item) => {
updateValue: async (item) => {
db.settings.setTimeFormat(item);
useSettingStore.setState({
timeFormat: item
@@ -217,15 +231,18 @@ export const TimeFormatPicker = createSettingsPicker({
return `${strings[item]()} (${dayjs().format(TimeFormats[item])})`;
},
getItemKey: (item) => item,
options: TIME_FORMATS,
options: TIME_FORMATS as TimeFormat[],
compareValue: (current, item) => current === item,
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const BackupReminderPicker = createSettingsPicker({
export const BackupReminderPicker = createSettingsPicker<
Settings["reminder"],
Settings["reminder"]
>({
getValue: () => useSettingStore.getState().settings.reminder,
updateValue: (item) => {
updateValue: async (item) => {
SettingsService.set({ reminder: item });
},
formatValue: (item) => {
@@ -237,49 +254,50 @@ export const BackupReminderPicker = createSettingsPicker({
requiresVerification: () => {
return (
!useSettingStore.getState().settings.encryptedBackup &&
useUserStore.getState().user
!!useUserStore.getState().user
);
},
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const BackupWithAttachmentsReminderPicker = createSettingsPicker({
getValue: () => useSettingStore.getState().settings.fullBackupReminder,
updateValue: (item) => {
updateValue: async (item) => {
SettingsService.set({ fullBackupReminder: item });
},
formatValue: (item) => {
//@ts-ignore
return item === "useroff" || item === "off" || item === "never"
? "Off"
: item.slice(0, 1).toUpperCase() + item.slice(1);
},
getItemKey: (item) => item,
options: ["never", "weekly", "monthly"],
options: ["never", "weekly", "monthly"] as Settings["fullBackupReminder"][],
compareValue: (current, item) => current === item,
requiresVerification: () => {
return (
!useSettingStore.getState().settings.encryptedBackup &&
useUserStore.getState().user
!!useUserStore.getState().user
);
},
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const ApplockTimerPicker = createSettingsPicker({
getValue: () => useSettingStore.getState().settings.appLockTimer,
updateValue: (item) => {
updateValue: async (item) => {
SettingsService.set({ appLockTimer: item });
},
formatValue: (item) => {
return item === -1
? strings.never()
: item === 0 || item === undefined
? strings.immediately()
: item === 1
? strings.minutes(1)
: strings.minutes(item);
? strings.immediately()
: item === 1
? strings.minutes(1)
: strings.minutes(item);
},
getItemKey: (item) => item.toString(),
options: [-1, 0, 1, 5, 15, 30],
@@ -287,6 +305,76 @@ export const ApplockTimerPicker = createSettingsPicker({
onVerify: () => {
return verifyUserWithApplock();
},
isFeatureAvailable: () => true,
isOptionAvailable: () => true
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const VaultLockTimerPicker = createSettingsPicker({
getValue: () => useSettingStore.getState().vaultLockAfter,
updateValue: async (item) => {
await db.settings.setVaultLockAfter(item);
useSettingStore.setState({
vaultLockAfter: item
});
},
formatValue: (item) => {
return item === -1
? strings.never()
: item < 1000 * 60 * 60
? strings.minutes(item / (1000 * 60))
: strings.hours(item / (1000 * 60 * 60));
},
getItemKey: (item) => item.toString(),
options: [
1000 * 60 * 1,
1000 * 60 * 5,
1000 * 60 * 10,
1000 * 60 * 15,
1000 * 60 * 30,
1000 * 60 * 45,
1000 * 60 * 60,
-1
],
compareValue: (current, item) => current === item,
isFeatureAvailable: async () => true,
isOptionAvailable: async () => true
});
export const ImageCompressionPicker = createSettingsPicker({
getValue: () => useSettingStore.getState().settings.imageCompression,
updateValue: async (item) => {
SettingsService.set({ imageCompression: item });
},
formatValue: (item) => {
return item === "ask-every-time"
? strings.askEveryTime()
: item === "enabled"
? strings.enableRecommended()
: strings.disable();
},
getItemKey: (item) => item,
options: [
"ask-every-time",
"enabled",
"disabled"
] as Settings["imageCompression"][],
compareValue: (current, item) => current === item,
isFeatureAvailable: async () => true,
isOptionAvailable: async (item) => {
const feature = await isFeatureAvailable("fullQualityImages");
if (!feature.isAllowed && item === "enabled") {
ToastManager.show({
message: feature.error,
type: "info",
actionText: strings.upgrade(),
func: () => {
PaywallSheet.present(feature);
}
});
return false;
}
return true;
}
});

View File

@@ -235,19 +235,22 @@ const _SectionItem = ({ item }: { item: SettingSection }) => {
<View
style={{
flexShrink: 1,
paddingRight: item.type === "switch" ? 10 : 0
paddingRight: item.type === "switch" ? 10 : 0,
flex: item.type === "component" ? 1 : 0
}}
>
<Heading
color={
item.type === "danger"
? colors.error.paragraph
: colors.primary.heading
}
size={AppFontSize.sm}
>
{typeof item.name === "function" ? item.name(current) : item.name}
</Heading>
{item.name ? (
<Heading
color={
item.type === "danger"
? colors.error.paragraph
: colors.primary.heading
}
size={AppFontSize.sm}
>
{typeof item.name === "function" ? item.name(current) : item.name}
</Heading>
) : null}
{!!item.description && (
<Paragraph

View File

@@ -66,6 +66,7 @@ import SettingsService from "../../services/settings";
import Sync from "../../services/sync";
import { clearAllStores } from "../../stores";
import { refreshAllStores } from "../../stores/create-db-collection-store";
import { useSettingStore } from "../../stores/use-setting-store";
import { useThemeStore } from "../../stores/use-theme-store";
import { useUserStore } from "../../stores/use-user-store";
import { EDITOR_LINE_HEIGHT } from "../../utils/constants";
@@ -429,36 +430,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()
},
@@ -706,6 +732,108 @@ export const settingsGroups: SettingSection[] = [
type: "screen",
description: strings.notesnookCircleDesc(),
component: "notesnook-circle"
},
{
id: "inbox-api",
name: strings.inboxAPI(),
icon: "inbox",
type: "screen",
description: strings.inboxAPIDesc(),
sections: [
{
id: "toggle-inbox-api",
name: strings.enableInboxAPI(),
description: strings.enableInboxAPIDesc(),
type: "switch",
useHook: () => {
return useSettingStore((state) => state.inboxEnabled);
},
getter: (current) => current,
modifer: async (current) => {
if (current) {
return new Promise((resolve) => {
presentDialog({
title: strings.disableInboxAPI(),
paragraph: strings.disableInboxAPIDesc(),
positiveText: strings.disable(),
onClose: () => {
resolve();
},
positivePress: async () => {
try {
await db.inboxItemsHistory.deleteFailed();
await db.user.discardInboxKeys();
useSettingStore.setState({
inboxEnabled: false
});
resolve();
return true;
} catch (e) {
ToastManager.show({
message: (e as Error).message,
context: "local"
});
DatabaseLogger.error(e);
return false;
}
}
});
});
}
try {
Navigation.push("SettingsGroup", {
id: "setup-inbox-keys",
name: strings.setupInboxKeys(),
type: "screen",
component: "setup-inbox-keys"
} as any);
} catch (e) {
console.log(e);
}
}
},
{
id: "manage-inbox-keys",
name: strings.manageInboxKeys(),
useHook: () => useSettingStore((state) => state.inboxEnabled),
hidden: (current) => !current,
description: strings.manageInboxKeysDesc(),
onVerify: async () => {
return new Promise((resolve) => {
verifyUser(
"global",
() => {
resolve(true);
},
false,
() => resolve(false)
);
});
},
type: "screen",
component: "manage-inbox-keys"
},
{
id: "inbox-keys",
name: strings.viewAPIKeys(),
description: strings.viewAPIKeysDesc(),
useHook: () => useSettingStore((state) => state.inboxEnabled),
hidden: (current) => !current,
type: "screen",
component: "inbox-keys"
},
{
id: "failed-inbox-items",
name: strings.failedInboxItems(),
description: strings.failedInboxItemsDesc(),
useHook: () => useSettingStore((state) => state.inboxEnabled),
hidden: (current) => !current,
type: "screen",
component: "failed-inbox-items",
hideHeader: true
}
]
}
]
},
@@ -836,6 +964,14 @@ export const settingsGroups: SettingSection[] = [
description: strings.autoUpdateCheckDesc(),
property: "checkForUpdates",
icon: "update"
},
{
id: "image-compression",
type: "component",
name: strings.imageCompression(),
description: strings.imageCompressionDesc(),
component: "image-compression-picker",
icon: "image-area"
}
]
},
@@ -995,6 +1131,16 @@ export const settingsGroups: SettingSection[] = [
});
}
},
{
id: "lock-vault-after",
type: "component",
useHook: useVaultStatus,
name: strings.lockVaultAfter(),
description: strings.lockVaultAfterDesc(),
hidden: (current) => !(current as VaultStatusType)?.exists,
component: "vault-lock-timer",
icon: "clock-outline"
},
{
id: "change-vault-password",
useHook: useVaultStatus,

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";
@@ -59,6 +63,8 @@ import { getElevationStyle } from "../../utils/elevation";
import { MenuItemsList } from "../../utils/menu-items";
import { AppFontSize, defaultBorderRadius } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { openLinkInBrowser } from "../../utils/functions";
import Clipboard from "@react-native-clipboard/clipboard";
const THEME_SERVER_URL = "https://themes-api.notesnook.com";
//@ts-ignore
@@ -69,7 +75,8 @@ export const themeTrpcClient = createTRPCProxyClient<ThemesRouter>({
})
]
});
notifyManager.setBatchNotifyFunction((cb) => cb());
notifyManager.setNotifyFunction((cb) => cb());
function ThemeSelector() {
const [darkTheme, lightTheme] = useThemeStore((state) => [
state.darkTheme,
@@ -79,35 +86,35 @@ function ThemeSelector() {
const { colors } = useThemeColors();
const themeColors = colors;
const [searchQuery, setSearchQuery] = useState<string>();
const [colorScheme, setColorScheme] = useState<string>();
const [colorScheme, setColorScheme] = useState<"all" | "dark" | "light">(
"all"
);
const filters = [];
if (searchQuery) filters.push({ type: "term" as const, value: searchQuery });
if (colorScheme !== "all")
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
}
);
if (themes?.isError) {
DatabaseLogger.error(
new Error(themes.error.message),
"themes loading error",
themes.error?.data || undefined
);
}
const select = (item: Partial<ThemeMetadata>, fromFile?: boolean) => {
presentSheet({
context: "theme-details",
@@ -117,204 +124,209 @@ function ThemeSelector() {
});
};
const renderItem = ({
item,
index
}: {
item: ThemeMetadata;
index: number;
}) => {
const colors =
item.previewColors ||
getPreviewColors(item as unknown as ThemeDefinition);
const renderItem = React.useCallback(
({ item, index }: { item: ThemeMetadata; index: number }) => {
const colors =
item.previewColors ||
getPreviewColors(item as unknown as ThemeDefinition);
return (
<>
<TouchableOpacity
activeOpacity={0.9}
style={{
borderRadius: 10,
padding: DefaultAppStyles.GAP_SMALL,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
flexShrink: 1,
marginHorizontal: 10
}}
onPress={() => select(item)}
>
<View
return (
<>
<TouchableOpacity
activeOpacity={0.9}
style={{
backgroundColor: colors?.background,
height: 200,
width: "100%",
borderRadius: 10,
padding: DefaultAppStyles.GAP_SMALL,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
overflow: "hidden",
flexDirection: "row",
justifyContent: "space-between",
...getElevationStyle(3)
flexShrink: 1,
marginHorizontal: 10
}}
onPress={() => select(item)}
>
<View
style={{
height: "100%",
width: "49.5%",
backgroundColor: colors.navigationMenu.background,
padding: DefaultAppStyles.GAP_SMALL,
paddingVertical: 3,
borderRadius: defaultBorderRadius
}}
>
{MenuItemsList.map((item, index) => (
<View
key={item.id}
style={{
height: 12,
width: "100%",
backgroundColor:
index === 0
? colors.navigationMenu.accent + 40
: colors.navigationMenu.background,
borderRadius: 2,
paddingHorizontal: 3,
flexDirection: "row",
alignItems: "center",
marginBottom: 4
}}
>
<Icon
size={8}
name={item.icon}
color={
index === 0
? colors.navigationMenu.accent
: colors.navigationMenu.icon
}
/>
<View
style={{
height: 3,
width: "40%",
backgroundColor:
index === 0
? colors.navigationMenu.accent
: colors.paragraph,
borderRadius: 2,
marginLeft: 3
}}
></View>
</View>
))}
</View>
<View
style={{
height: "100%",
width: "49.5%",
backgroundColor: colors.list.background,
borderRadius: defaultBorderRadius,
paddingHorizontal: 2,
paddingRight: 6
backgroundColor: colors?.background,
height: 200,
width: "100%",
borderRadius: 10,
marginBottom: DefaultAppStyles.GAP_VERTICAL,
overflow: "hidden",
flexDirection: "row",
justifyContent: "space-between",
...getElevationStyle(3)
}}
>
<View
style={{
height: 12,
width: "100%",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
marginTop: 3
height: "100%",
width: "49.5%",
backgroundColor: colors.navigationMenu.background,
padding: DefaultAppStyles.GAP_SMALL,
paddingVertical: 3,
borderRadius: defaultBorderRadius
}}
>
{MenuItemsList.map((item, index) => (
<View
key={item.id}
style={{
height: 12,
width: "100%",
backgroundColor:
index === 0
? colors.navigationMenu.accent + 40
: colors.navigationMenu.background,
borderRadius: 2,
paddingHorizontal: 3,
flexDirection: "row",
alignItems: "center",
marginBottom: 4
}}
>
<Icon
size={8}
name={item.icon}
color={
index === 0
? colors.navigationMenu.accent
: colors.navigationMenu.icon
}
/>
<View
style={{
height: 3,
width: "40%",
backgroundColor:
index === 0
? colors.navigationMenu.accent
: colors.paragraph,
borderRadius: 2,
marginLeft: 3
}}
></View>
</View>
))}
</View>
<View
style={{
height: "100%",
width: "49.5%",
backgroundColor: colors.list.background,
borderRadius: defaultBorderRadius,
paddingHorizontal: 2,
paddingRight: 6
}}
>
<View
style={{
height: 12,
width: "100%",
flexDirection: "row",
alignItems: "center"
justifyContent: "space-between",
alignItems: "center",
marginTop: 3
}}
>
<Icon size={8} color={colors.list.heading} name="menu" />
<Heading
<View
style={{
marginLeft: 3
flexDirection: "row",
alignItems: "center"
}}
color={colors.list.heading}
size={7}
>
{strings.dataTypesPluralCamelCase.note()}
</Heading>
</View>
<Icon size={8} color={colors.list.heading} name="menu" />
<Heading
style={{
marginLeft: 3
}}
color={colors.list.heading}
size={7}
>
{strings.dataTypesPluralCamelCase.note()}
</Heading>
</View>
<Icon name="magnify" color={colors.list.heading} size={7} />
<Icon name="magnify" color={colors.list.heading} size={7} />
</View>
</View>
<View
style={{
width: "100%",
alignItems: "flex-end",
justifyContent: "flex-end",
marginTop: DefaultAppStyles.GAP_VERTICAL_SMALL,
position: "absolute",
bottom: 6,
right: 6,
flexDirection: "row",
gap: 10
}}
>
{darkTheme.id === item.id || lightTheme.id === item.id ? (
<IconButton
name="check"
type="plain"
style={{
borderRadius: 100,
paddingHorizontal: 6,
alignSelf: "flex-end",
width: 25,
height: 25
}}
color={colors.accent}
size={16}
/>
) : null}
<Button
title={
item.colorScheme === "dark"
? strings.dark()
: strings.light()
}
type="secondaryAccented"
height={25}
buttonType={{
color: item.colorScheme === "dark" ? "black" : "#f0f0f060",
text: colors.accent
}}
style={{
borderRadius: 100,
paddingHorizontal: DefaultAppStyles.GAP,
alignSelf: "flex-end",
borderColor:
item.colorScheme === "dark"
? getColorLinearShade("#000000", 0.1, true)
: getColorLinearShade("#f0f0f0", 0.1, true)
}}
fontSize={AppFontSize.xxs}
/>
</View>
</View>
<View
style={{
width: "100%",
alignItems: "flex-end",
justifyContent: "flex-end",
marginTop: DefaultAppStyles.GAP_VERTICAL_SMALL,
position: "absolute",
bottom: 6,
right: 6,
flexDirection: "row",
gap: 10
}}
<Heading size={AppFontSize.sm} color={themeColors.primary.heading}>
{item.name}
</Heading>
<Paragraph
size={AppFontSize.xs}
color={themeColors.secondary?.paragraph}
>
{darkTheme.id === item.id || lightTheme.id === item.id ? (
<IconButton
name="check"
type="plain"
style={{
borderRadius: 100,
paddingHorizontal: 6,
alignSelf: "flex-end",
width: 25,
height: 25
}}
color={colors.accent}
size={16}
/>
) : null}
{strings.by()} {item.authors?.[0].name}
</Paragraph>
</TouchableOpacity>
</>
);
},
[
darkTheme.id,
lightTheme.id,
themeColors.primary.heading,
themeColors.secondary?.paragraph
]
);
<Button
title={
item.colorScheme === "dark" ? strings.dark() : strings.light()
}
type="secondaryAccented"
height={25}
buttonType={{
color: item.colorScheme === "dark" ? "black" : "#f0f0f060",
text: colors.accent
}}
style={{
borderRadius: 100,
paddingHorizontal: DefaultAppStyles.GAP,
alignSelf: "flex-end",
borderColor:
item.colorScheme === "dark"
? getColorLinearShade("#000000", 0.1, true)
: getColorLinearShade("#f0f0f0", 0.1, true)
}}
fontSize={AppFontSize.xxs}
/>
</View>
</View>
<Heading size={AppFontSize.sm} color={themeColors.primary.heading}>
{item.name}
</Heading>
<Paragraph
size={AppFontSize.xs}
color={themeColors.secondary?.paragraph}
>
{strings.by()} {item.authors?.[0].name}
</Paragraph>
</TouchableOpacity>
</>
);
};
let resetTimer: NodeJS.Timeout;
const onSearch = (text: string) => {
clearTimeout(resetTimer as NodeJS.Timeout);
@@ -334,11 +346,14 @@ function ThemeSelector() {
.flat()
.filter((theme) =>
searchQuery && searchQuery !== ""
? true
: darkTheme.id !== theme.id && lightTheme.id !== theme.id
? colorScheme === "all" || colorScheme === theme.colorScheme
: darkTheme.id !== theme.id &&
lightTheme.id !== theme.id &&
(colorScheme === "all" || colorScheme === theme.colorScheme)
) || []
);
}
return (
<>
<SheetProvider context="theme-details" />
@@ -366,25 +381,28 @@ 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"
colorScheme === "all" || !colorScheme ? "accent" : "secondary"
}
title={strings.all()}
fontSize={AppFontSize.xs}
onPress={() => {
setColorScheme("");
setColorScheme("all");
}}
/>
<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 +413,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 +428,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"
@@ -441,14 +461,55 @@ function ThemeSelector() {
ReactNativeBlobUtil.fs
.unlink(themeJsonCopiedPath)
.catch(() => {});
const json = JSON.parse(themeJson);
let json;
try {
json = JSON.parse(themeJson);
} catch (e) {
ToastManager.show({
heading: strings.invalidThemeFileFormat(),
type: "error",
context: "global",
actionText: strings.learnMore(),
func: () => {
openLinkInBrowser(
"https://help.notesnook.com/custom-themes/introduction"
);
}
});
return;
}
const result = validateTheme(json);
if (result.error) {
ToastManager.error(new Error(result.error));
if (
typeof result.error === "string" &&
result.error.includes("missing from the theme")
) {
ToastManager.show({
heading: strings.themeMissingRequiredFields(),
type: "error",
context: "global",
actionText: strings.copyLogs(),
func: () => {
Clipboard.setString(result.error || "");
ToastManager.show({
heading: strings.logsCopied(),
type: "success",
context: "global"
});
}
});
} else {
ToastManager.error(new Error(result.error));
}
return;
}
select(json, true);
} catch (e) {
if ((e as Error).message.includes("Code=3072")) {
return;
}
ToastManager.error(e as Error);
}
}}
@@ -458,15 +519,21 @@ function ThemeSelector() {
<LegendList
numColumns={2}
data={[
...(colorScheme === "dark" || (searchQuery && searchQuery !== "")
data={
themes.isLoading || themes.isError
? []
: [lightTheme as unknown as ThemeMetadata]),
...(colorScheme === "light" || (searchQuery && searchQuery !== "")
? []
: [darkTheme as unknown as ThemeMetadata]),
...getThemes()
]}
: [
...(colorScheme === "dark" ||
(searchQuery && searchQuery !== "")
? []
: [lightTheme as unknown as ThemeMetadata]),
...(colorScheme === "light" ||
(searchQuery && searchQuery !== "")
? []
: [darkTheme as unknown as ThemeMetadata]),
...getThemes()
]
}
ListEmptyComponent={
<View
style={{
@@ -488,20 +555,23 @@ function ThemeSelector() {
</View>
}
ListFooterComponent={
themes.isError ? (
<View
style={{
height: 100,
width: "100%",
justifyContent: "center",
alignItems: "center"
}}
>
<View
style={{
height: 100,
width: "100%",
justifyContent: "center",
alignItems: "center"
}}
>
{themes.isError ? (
<Paragraph color={colors.error.paragraph}>
{strings.errorLoadingThemes()}. {themes.error.message}.
</Paragraph>
</View>
) : null
) : (themes.isLoading || themes.isFetching) &&
getThemes().length ? (
<ActivityIndicator color={colors.primary.accent} />
) : null}
</View>
}
estimatedItemSize={200}
renderItem={renderItem}

View File

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

View File

@@ -28,7 +28,17 @@ import { NotesnookModule } from "../utils/notesnook-module";
import { scale, updateSize } from "../utils/size";
import { useUserStore } from "../stores/use-user-store";
import ScreenGuardModule from "react-native-screenguard";
ScreenGuardModule.initSettings();
let isScreenGuardModuleReady = false;
async function callScreenGuard(callback: () => void) {
try {
if (!isScreenGuardModuleReady) {
await ScreenGuardModule.initSettings();
isScreenGuardModuleReady = true;
}
callback();
} catch (e) {}
}
function reset() {
const settings = get();
@@ -129,15 +139,21 @@ function setPrivacyScreen(enabled?: boolean) {
if (enabled) {
if (Platform.OS === "android") {
NotesnookModule.setSecureMode(true);
ScreenGuardModule.registerWithoutEffect();
callScreenGuard(() => {
ScreenGuardModule.registerWithoutEffect();
});
} else {
ScreenGuardModule.register({ backgroundColor: "#000000" });
callScreenGuard(() => {
ScreenGuardModule.register({ backgroundColor: "#000000" });
});
}
} else {
if (Platform.OS === "android") {
NotesnookModule.setSecureMode(false);
}
ScreenGuardModule.unregister();
callScreenGuard(() => {
ScreenGuardModule.unregister();
});
}
}
@@ -256,7 +272,8 @@ export const SettingsService = {
canLockAppInBackground,
appEnteredBackground,
setPrivacyScreen,
getBackgroundEnterTime
getBackgroundEnterTime,
callScreenGuard
};
init();

View File

@@ -31,6 +31,7 @@ import { useNotebookStore } from "./use-notebook-store";
import { useNoteStore } from "./use-notes-store";
import { useRelationStore } from "./use-relation-store";
import { useReminderStore } from "./use-reminder-store";
import { useSettingStore } from "./use-setting-store";
import { useTagStore } from "./use-tag-store";
import { useTrashStore } from "./use-trash-store";
import { useUserStore } from "./use-user-store";
@@ -121,6 +122,7 @@ export function initAfterSync(type: "full" | "send" = "send") {
useUserStore.setState({
profile: db.settings.getProfile()
});
useSettingStore.getState().refresh();
}
Notifications.setupReminders(true);

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

@@ -103,6 +103,7 @@ export type Settings = {
defaultSidebarTab: number;
checkForUpdates?: boolean;
defaultLineHeight: number;
imageCompression: "ask-every-time" | "enabled" | "disabled";
};
type DimensionsType = {
@@ -140,10 +141,13 @@ export interface SettingStore {
dateFormat: string;
dayFormat: DayFormat;
weekFormat: WeekFormat;
vaultLockAfter: number;
dbPassword?: string;
isOldAppLock: () => boolean;
initialUrl: string | null;
refresh: () => void;
inboxEnabled: boolean;
setInboxEnabled: (inboxEnabled: boolean) => void;
}
const { width, height } = Dimensions.get("window");
@@ -208,7 +212,8 @@ export const defaultSettings: SettingStore["settings"] = {
fullBackupReminder: "never",
lastFullBackupDate: 0,
checkForUpdates: true,
defaultLineHeight: EDITOR_LINE_HEIGHT.DEFAULT
defaultLineHeight: EDITOR_LINE_HEIGHT.DEFAULT,
imageCompression: "ask-every-time"
};
export const useSettingStore = create<SettingStore>((set, get) => ({
@@ -233,6 +238,7 @@ export const useSettingStore = create<SettingStore>((set, get) => ({
dateFormat: "DD-MM-YYYY",
dayFormat: "short",
weekFormat: "Sun",
vaultLockAfter: 1000 * 60 * 30,
setAppDidEnterBackgroundForAction: (value: boolean) => {
set({
appDidEnterBackgroundForAction: value
@@ -250,12 +256,16 @@ export const useSettingStore = create<SettingStore>((set, get) => ({
? initialWindowMetrics.insets
: { top: 0, right: 0, left: 0, bottom: 0 },
initialUrl: null,
refresh: () => {
refresh: async () => {
set({
dayFormat: db.settings.getDayFormat(),
timeFormat: db.settings.getTimeFormat(),
dateFormat: db.settings?.getTimeFormat(),
weekFormat: db.settings.getWeekFormat()
weekFormat: db.settings.getWeekFormat(),
vaultLockAfter: db.settings.getVaultLockAfter(),
inboxEnabled: await db.user.hasInboxKeys()
});
}
},
inboxEnabled: false,
setInboxEnabled: (inboxEnabled) => set({ inboxEnabled })
}));

View File

@@ -56,24 +56,26 @@ const USER = {
async function login() {
await TestBuilder.create()
.waitAndTapByText("Login to encrypt and sync notes")
.typeTextById("input.email", USER.login.email!)
.replaceTextById("input.email", USER.login.email!)
.tapReturnKeyById("input.email")
.wait(3000)
.typeTextById("input.totp", authenticator.generate(USER.login.totpSecret!))
.replaceTextById(
"input.totp",
authenticator.generate(USER.login.totpSecret!)
)
.waitAndTapByText("Next")
.wait(3000)
.typeTextById("input.password", USER.login.password!)
.replaceTextById("input.password", USER.login.password!)
.tapReturnKeyById("input.password")
.wait(4000)
.tapById("paywall-close")
.wait(3000)
.isVisibleById("Search in Notes")
.run();
}
describe("AUTH", () => {
it("Login", async () => {
await TestBuilder.create()
.prepare()
.addStep(login)
.wait(3000)
.isNotVisibleByText("Notesnook Plans")
.run();
await TestBuilder.create().prepare().addStep(login).run();
});
});

View File

@@ -315,6 +315,12 @@ class TestBuilder {
});
}
replaceTextById(id: string, text: string) {
return this.addStep(async () => {
await Element.fromId(id).element.replaceText(text);
});
}
clearTextById(id: string) {
return this.addStep(async () => {
await Element.fromId(id).element.clearText();

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