Compare commits

...

154 Commits

Author SHA1 Message Date
Abdullah Atta
4c702af1ca core: minor refactor + add test for password change (#10291) 2026-08-27 09:55:24 +05:00
Masatoshi Ogiwara
725df6f6f7 web: fix duplicate task id in deleteItemsFromTrash (#10286)
deleteItemsFromTrash reused the "restoreItems" task id from
restoreItemsFromTrash (copy-paste), so a permanent-delete and a
restore running close together shared the same status entry: the one
that finished first cleared the other's progress indicator, and the
displayed status text could reflect the wrong operation. Give the
delete task its own "deleteItems" id.

Signed-off-by: zigzagdev <msts.oo0131@gmail.com>
2026-08-27 09:03:58 +05:00
Abdullah Atta
c9c4936d9e Merge pull request #10251 from streetwriters/core/fix-password-recovery
Fix account recovery
2026-08-25 19:22:07 +05:00
Abdullah Atta
9e251d6f9e core: fix non-legacy users can't change password 2026-08-25 16:12:07 +05:00
01zulfi
9190a1de3f web: persist theme's color scheme when setting a new theme (#10270)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-25 09:46:58 +05:00
Ammar Ahmed
5d03d3033f Merge pull request #10281 from streetwriters/mobile-release/3.4.10
mobile: release v3.4.10
2026-08-25 08:48:03 +05:00
Ammar Ahmed
db8f02a585 mobile: release v3.4.10 2026-08-24 13:39:23 +05:00
Ammar Ahmed
0fd305a259 Merge pull request #10220 from streetwriters/mobile-release/3.4.9
mobile: release v3.4.9
2026-08-24 13:26:15 +05:00
Ammar Ahmed
b855266293 Merge pull request #10208 from kashaf-ansari-dev/fix-compress-checkbox-not-toggling
mobile: re-check fullQualityImages availability on every compress tog…
2026-08-24 13:23:48 +05:00
Ammar Ahmed
b4227a8c43 Merge pull request #10222 from kashaf-ansari-dev/fix-9053-restore-editor-scroll-position
Fix 9053 restore editor scroll position
2026-08-24 13:23:02 +05:00
Ammar Ahmed
39887f81a2 Merge pull request #10257 from streetwriters/fix/performance
mobile: fix groupOptions causes unnecessary rerenders on first load
2026-08-24 13:22:15 +05:00
Ammar Ahmed
e47ef061fd Merge pull request #10240 from kashaf-ansari-dev/fix-9014-respect-reduce-animations-setting
mobile: respect device reduce animations system setting for sheets
2026-08-24 13:20:52 +05:00
Ammar Ahmed
360cd5431d Merge pull request #10258 from streetwriters/cleanup/master
global: cleanup
2026-08-19 13:25:40 +05:00
Ammar Ahmed
c3aff365f1 global: cleanup 2026-08-19 13:24:34 +05:00
kashaf-ansari-dev
dd3ced17de mobile: re-check fullQualityImages availability on every compress toggle tap
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-08-18 18:56:58 +05:00
Abdullah Atta
ee419e514c web: fix Requiring @playwright/test second time when running tests (#10252) 2026-08-18 10:29:06 +05:00
Abdullah Atta
a3d672362f core: add tests covering password reset & other crypto apis 2026-08-17 19:12:44 +05:00
Abdullah Atta
04f9ff41a0 web: verify encryption key during password reset 2026-08-17 19:12:28 +05:00
Abdullah Atta
54c97584cf core: add encryption key verification on password change 2026-08-17 19:12:07 +05:00
01zulfi
db72716234 Merge pull request #10248 from streetwriters/release/3.4.6
web: bump version to 3.4.6
2026-08-17 17:17:05 +05:00
01zulfi
72351960fa web: fix trailing slash when saving cors proxy url
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-17 14:44:58 +05:00
01zulfi
85458932de web: bump version to 3.4.6
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-17 14:07:02 +05:00
Abdullah Atta
4e1acc292d web: Improve auth ui on web/desktop (#10218)
* web: improve auth ui

* web: fix password show/hide icons

* web: fix auth form width

* web: fix minor ui issues with back button & recovery view
2026-08-17 11:41:18 +05:00
01zulfi
20b4585361 editor: fix multiple link hover popups from opening (#10233)
* also fix the popup not opening when quickly hovering one link after another
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-17 10:11:46 +05:00
01zulfi
dc3f38f67d ui: truncate long menu titles (#10237)
* On web, long tags in menu items in the editor header would take a lot of width. The fix in the ui package will fix it for all usages of long menu items on web

Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-17 10:11:17 +05:00
01zulfi
a40e11f2d4 web: preserve port in cors proxy url (#10234)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-17 10:10:54 +05:00
Abdullah Atta
ccc152b561 docs(help): fix links ignoring baseUrl on docs index page (#10243) 2026-08-16 21:46:39 +05:00
kashaf-ansari-dev
279c776e45 mobile: respect device reduce animations system setting for sheets
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-08-14 17:56:22 +05:00
Abdullah Atta
87aa98b85e global: move everything to notesnook.com/help/* (#10238) 2026-08-14 12:34:07 +05:00
Ammar Ahmed
b7777a2a19 mobile: fix groupOptions causes unnecessary rerenders on first load 2026-08-14 10:55:53 +05:00
Ammar Ahmed
52d6044e73 docs(help): rebuild the help site with VitePress (#10169)
* docs: new help built with vitepress.

* docs: improve docs home ui

Added a Go to Docs button on homepage
Added a searchbar for directly searching what you are looking for in the docs

* docs(help): wire up with build system & setup proper ci

* Clean up new documentation (#10174)

* Docs: Cleanup pass 1

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* docs: cleanup pass 2

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* docs: Fix vscode's manglement that I missed.

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* Update docs/help/contents/plans-and-limits.md

Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* Update docs/help/contents/rich-text-editor/outline-lists.md

Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* docs: Remove self-hosting guide from sidebar, and comments for reviewer.

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

---------

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>
Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>

* docs: some fixes

* ci: do not publish help on push

* docs(help): improve regional pricing & free trials

* docs: improve sidebar

* docs: some more fixes after re-review

* docs: improve search and nav

* docs: a few more fixes

* docs: fix tabs formatting compat with prettier

* docs: fix tabs formatting in various places

* docs: show correct plus button image for mobile

* docs: disable notesnook self hosting docs

* docs: update help docs with fixes

---------

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>
Co-authored-by: Abdullah Atta <abdullahatta@streetwriters.co>
Co-authored-by: Chloe Oletto <NeedsChloesure@riseup.net>
Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
2026-08-14 08:57:51 +05:00
01zulfi
8d56564221 web: fix notes not exporting from archive list multiple selection (#10230)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-14 08:28:42 +05:00
01zulfi
d647eb8187 web: show error when unlock note password is empty (#10231)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-08-13 08:36:12 +05:00
kashaf-ansari-dev
316951c2bf editor: guard scroll persistence until note session is restored
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-08-11 09:36:50 +05:00
kashaf-ansari-dev
9ce5b76265 mobile: restore latest tab scroll position
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-08-10 20:03:22 +05:00
Ammar Ahmed
a82bb4cc32 mobile: release v3.4.9 2026-08-10 14:29:00 +05:00
Ammar Ahmed
b181655edc Merge pull request #10147 from kashaf-ansari-dev/fix/10011-recovery-key-font
mobile: improve recovery key readability
2026-08-10 14:12:46 +05:00
Ammar Ahmed
67ffcb163c Merge pull request #10183 from kashaf-ansari-dev/fix-10165-empty-trash-note
mobile: allow opening empty trashed notes without contentId
2026-08-10 14:12:12 +05:00
Ammar Ahmed
982cea7aa6 Merge pull request #10182 from kashaf-ansari-dev/mobile/improve-skip-wording
mobile: improve skip & go directly to app
2026-08-10 14:11:32 +05:00
Ammar Ahmed
adab66f285 Merge pull request #10198 from kashaf-ansari-dev/fix-rniap-play-billing
mobile: bump Play Billing Library to 8.0.0 via patch-package for reac…
2026-08-10 14:10:44 +05:00
Ammar Ahmed
1740ba9e10 mobile: bump Play Billing Library to 8.0.0 via patch-package for react-native-iap
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-08-10 14:09:22 +05:00
Ammar Ahmed
9e5ff04692 Merge pull request #10137 from streetwriters/fix/task-list-touch-drag
editor: drag & drop task list items with pointer events
2026-08-10 14:00:26 +05:00
Ammar Ahmed
08bb216334 editor: apply text direction to a whole list, not just the cursor's item 2026-08-09 18:00:53 +05:00
Ammar Ahmed
01da7cbb19 editor: render the drag preview in the editor's own context, 1:1 2026-08-05 19:55:23 +05:00
Ammar Ahmed
e326b912fd editor: RTL nesting, cheaper move and scroller lookup 2026-08-05 19:55:23 +05:00
Abdullah Atta
8bc072a543 editor: remove unnecessary comments
Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
Signed-off-by: Abdullah Atta <thecodrr@protonmail.com>
2026-08-05 19:55:23 +05:00
Ammar Ahmed
79086108a5 editor: drop placeholder on first item on task list should not hide when moving the dragged item above it or above the task list header. it should safely drop as the first list item.
- Improve drag/drop reliability be increasing the hit slop area for starting the drag.

- Ensure drag survives between rerenders
- Add a solid background to dragged item so it doesn't conflict with items underneath it
2026-08-05 19:55:23 +05:00
Ammar Ahmed
1070831b63 editor: only drop a task item into a task list & fix crash 2026-08-05 19:55:23 +05:00
Ammar Ahmed
efaffc8729 editor: drag task list items with pointer events 2026-08-05 19:55:23 +05:00
Ammar Ahmed
b8a02640d6 Merge pull request #10193 from streetwriters/mobile/release-3.4.8
mobile: release v3.4.8
2026-08-04 19:24:16 +05:00
Ammar Ahmed
31dbbe5e9e mobile: fix rn 82 build on xcode 26.5 2026-08-04 12:11:54 +05:00
kashaf-ansari-dev
696f57423b mobile: improve recovery key readability
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-08-04 10:57:47 +05:00
Ammar Ahmed
427dcbbb20 mobile: release v3.4.8 2026-08-03 19:30:12 +05:00
Ammar Ahmed
d3d21aa835 Merge pull request #10161 from streetwriters/fix/android-widgets
Fix android widget bugs.
2026-08-03 19:29:33 +05:00
Ammar Ahmed
d60c8be423 Merge pull request #10019 from kashaf-ansari-dev/feat/9694-new-reminder-shortcut
mobile: add app shortcut for creating reminders
2026-08-03 12:28:09 +05:00
kashaf-ansari-dev
4d97f7033f mobile: remove accent color on dialogue text and estrics from the string
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-08-03 10:49:31 +05:00
Ammar Ahmed
516a598b2b Merge pull request #10166 from kashaf-ansari-dev/fix-10135-image-note-save
mobile: fix images sometimes not saving after insert
2026-08-03 09:58:06 +05:00
kashaf-ansari-dev
966d57dfa2 mobile: allow opening empty trashed notes without contentId
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-31 18:04:59 +05:00
kashaf-ansari-dev
7a3111c6b3 mobile: improve skip & go directly to app
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-31 13:07:16 +05:00
Abdullah Atta
f858e3471f desktop: set snap base to core24 (#10180) 2026-07-31 12:41:56 +05:00
kashaf-ansari-dev
57113e0896 mobile: remove AddReminder from stack on navigation away to prevent stale form state
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-31 09:49:36 +05:00
kashaf-ansari-dev
28f2ffdbf7 mobile: remove unused ignoreEdit handling
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-30 23:29:32 +05:00
kashaf-ansari-dev
02c60d14c7 editor: skip ignoreEdit updates before scheduling save
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-30 23:29:00 +05:00
kashaf-ansari-dev
41cdc882c9 editor: fix ignoreEdit flag overwritten during debounced save
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-30 23:29:00 +05:00
Suyadi
a1879dae17 mobile: fix error on android's search query (#10106)
* mobile: pass value for items when searching on main screen to match other screens' pattern
Signed-off-by: Suyadi <afsuyadi@gmail.com>

* core: guard notesWithHighlighting against an undefined notes selector.
Signed-off-by: Suyadi <afsuyadi@gmail.com>

* core: add regression test for notesWithHighlighting crash.
Signed-off-by: Suyadi <afsuyadi@gmail.com>

* core: revert changes after feedback
Signed-off-by: Suyadi <afsuyadi@gmail.com>

* mobile: ensure type: "note" will make items become mandatory.
Signed-off-by: Suyadi <afsuyadi@gmail.com>

* core: remove tests after feedback
Signed-off-by: Suyadi <afsuyadi@gmail.com>
2026-07-30 23:01:21 +05:00
Ammar Ahmed
4765657423 mobile: fix editor cross contamination (#10157)
1. Use noteId defined in editorMessage when saving content, never use tabId since it can point to a different note since a tab can load a different note while a note save message is coming across the bridge.
2. If saving fails and editor saves save payloads in localStorage, add the noteId to them so we know which note the content belongs to.
3. Pending content saves must keep noteId and edit time so when we save them later, they save into the correct note and if the content is newer, we skip saving.
4. Fix the debounce key so cross contamination can never occur in new notes between two tabs.
2026-07-30 22:55:06 +05:00
Ammar Ahmed
eef56916de mobile: Upcoming Reminders -> Reminders in home widgets 2026-07-30 09:40:43 +05:00
Abdullah Atta
f7c755f2d6 editor: sort code block languages by recently used (#10144) 2026-07-30 08:57:38 +05:00
Kashaf Ansari
10a08d5719 mobile: fix add notes to notebook selection behavior (#10104)
* mobile: fix add notes to notebook selection behavior

Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>

* mobile: improve move notes keyboard interactions

Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>

* mobile: fix bottom inset when keyboard is open

---------

Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
Co-authored-by: Ammar Ahmed <ammarahmed6506@gmail.com>
2026-07-30 08:56:18 +05:00
Kashaf Ansari
6e10ce8d8e mobile: add sync status button to side menu (#10112)
* mobile: add sync status button to side menu

Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>

* mobile: minor fixes

* mobile: hide sync status during logout

Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>

* mobile: skipped sync during logout

Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>

---------

Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
Co-authored-by: Ammar Ahmed <ammarahmed6506@gmail.com>
2026-07-30 08:54:05 +05:00
Ammar Ahmed
7f5215403b mobile: keep reminders showing in widget after they go off for 3 hours 2026-07-29 12:14:15 +05:00
Ammar Ahmed
8e1d59d016 mobile: fix reminder widget title vertical padding 2026-07-29 10:01:29 +05:00
Ammar Ahmed
9833c7636b mobile: reminder widget does not show up on some devices 2026-07-29 09:37:26 +05:00
Ammar Ahmed
71cae07dc0 mobile: update widgets on time changed
When system time changes, reminder widgets keep showing reminders that have passed as active.
2026-07-29 09:13:59 +05:00
Ammar Ahmed
77e56f9da1 mobile: fix widgets do not refresh after app data is cleared, require a restart. 2026-07-29 09:08:03 +05:00
Abdullah Atta
b5140d9947 Merge pull request #10159 from streetwriters/release/3.4.5
web: bump version to 3.4.5
2026-07-28 21:09:08 +05:00
Abdullah Atta
2288b946ad desktop: launch app with --hidden flag for tests 2026-07-28 19:53:37 +05:00
Abdullah Atta
5fd43ae08e desktop: fix auto start with minimized window on macos 2026-07-28 19:53:37 +05:00
Abdullah Atta
729c410602 global: update intl & lockfiles 2026-07-28 19:53:37 +05:00
Abdullah Atta
bb30336f5c desktop: add tests for #9942 2026-07-28 19:52:48 +05:00
01zulfi
0dc89ba945 web: bump version to 3.4.5
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-28 19:52:47 +05:00
Abdullah Atta
bb51244fa3 editor: update prosemirror packages (#10158)
* editor: update prosemirror packages

* editor: fix `__serializeForClipboard` usage
2026-07-28 19:46:51 +05:00
Abdullah Atta
b5ac41c29b web: improve "skip & go directly to app" wording (#10167) 2026-07-28 19:46:23 +05:00
01zulfi
aafb6049b2 core: delete all vaults when user triggers their vault deletion (#9939)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-28 19:45:30 +05:00
Ammar Ahmed
b066fac692 mobile: drop unused imports from widget source 2026-07-27 12:24:55 +05:00
Ammar Ahmed
8d2e1c65bc mobile: keep note and reminder content off the lock screen
From Android 16 QPR1 widgets are lock screen eligible by default, so the
note widget (title + preview text) and the reminders widget (titles and
descriptions) could render user content on a locked device.

Adds res/xml-v36 overrides declaring not_keyguard for both. The new note
widget is left eligible - it is only a button and leaks nothing.
2026-07-27 12:21:49 +05:00
Ammar Ahmed
93337b6560 mobile: remove dead widget theme overlay, use system corner radius
ThemeOverlay.Notesnook.AppWidgetContainer declared and set
appWidgetBackgroundColor/appWidgetTextColor, but no layout ever read
them - leftovers from the Android Studio widget template, in a palette
unrelated to the app. Removes the overlay, its declare-styleable, the
android:theme references, and the light_blue_* colors that only it used.

Also replaces the hardcoded 10dp widget corner radius with
system_app_widget_background_radius on API 31+ (10dp fallback below),
so the widgets match the rest of the home screen.
2026-07-27 12:14:13 +05:00
Ammar Ahmed
70fd3096d2 mobile: fix invisible empty state in the reminders widget 2026-07-27 12:10:25 +05:00
Ammar Ahmed
1889b13459 mobile: remove stray quote in NotePreviewWidget manifest entry 2026-07-27 12:05:28 +05:00
Ammar Ahmed
314c484ea0 mobile: fix note widget lookups corrupting the reminders list
updateWidgetNote scanned every key in the appPreview preferences and
rewrote any whose value contained the note id. That included the
remindersList key, so a match replaced the entire reminders list with a
note and then threw NumberFormatException parsing "remindersList" as a
widget id. hasWidgetNote had the same unguarded scan.

Match on the parsed note's id instead of on the raw JSON containing it,
and treat only keys that parse as an int as widget entries, so non-widget
keys are excluded structurally rather than by name. Also guards the casts
and gson parses that could throw on a malformed entry.
2026-07-27 12:01:31 +05:00
Ammar Ahmed
6b5e6f62c5 mobile: make NotePreviewWidget respond to resize events and properly resize based on available room. 2026-07-27 11:25:34 +05:00
Ammar Ahmed
55b51b81e3 mobile: add previewLayout for all widgets 2026-07-27 11:17:12 +05:00
Ammar Ahmed
b2c7f732a0 mobile: replace reminders widget service with RemoteCollectionItems
Push the reminder rows into the widget update itself via RemoteViewsCompat
instead of binding a RemoteViewsService adapter, which has been deprecated
since API 31. Removes the bound service entirely, so there is no cached
factory to invalidate and no separate notifyAppWidgetViewDataChanged step
that can race or be missed.

Adds androidx.core:core-remoteviews for the API 24-30 fallback. Row ids are
now derived from the reminder id rather than the list position, and the list
is capped at 50 rows so the update fits in a binder transaction.
2026-07-27 11:10:46 +05:00
Ammar Ahmed
88eb3ab714 mobile: fix NotePreviewWidget empty state has no click handler — make it tap-to-configure so a blank widget can self-heal 2026-07-27 10:49:36 +05:00
Ammar Ahmed
942c9287fe mobile: fix stale reminder times — render-time formatting + valid updatePeriodMillis + self-refresh 2026-07-27 10:39:39 +05:00
Ammar Ahmed
343d2b0131 core: export getUpcomingReminderTime 2026-07-27 10:39:20 +05:00
Abdullah Atta
32070327f9 core: add logs to diagnose why decryption fails after changing password (#10139) 2026-07-27 10:20:25 +05:00
Ammar Ahmed
9b29503b17 mobile: added onRestored() that migrates each stored note in the appPreview prefs from its old widget ID key to the new one. 2026-07-27 10:08:16 +05:00
Ammar Ahmed
0939f0f805 mobile: migrated off the deprecated background-activity-launch mode
API 36+ → MODE_BACKGROUND_ACTIVITY_START_ALLOW_IF_VISIBLE
API 34–35 → MODE_BACKGROUND_ACTIVITY_START_ALLOWED (unchanged behaviour on older devices)
< 34 → null
2026-07-27 09:56:18 +05:00
Abdullah Atta
771a8bb4e3 web: fix editor undo history reset on image delete (#10143) 2026-07-25 08:55:58 +05:00
Ammar Ahmed
e4fa0e9f0b mobile: fix ios app does not load if awaited RNBootSplash.hide 2026-07-25 08:54:44 +05:00
kashaf-ansari-dev
97749729ad mobile: improve startup loading shortcuts
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-24 18:57:54 +05:00
Ammar Ahmed
ca19fd9230 mobile: fix new note loading from shortcut on cold launch 2026-07-24 18:57:54 +05:00
kashaf-ansari-dev
c435ab46d1 mobile: simplified new editor load logic
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-24 18:57:54 +05:00
kashaf-ansari-dev
62ecc5f7fb mobile: resolve app shortcut navigation and add reminder feature gate
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-24 18:57:54 +05:00
kashaf-ansari-dev
3e2847f844 mobile: add fluid panels initialRoute to simplified navigation
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-24 18:57:54 +05:00
kashaf-ansari-dev
c3c9b1fbf4 mobile: fix app shortcut navigation, flash, and back-nav issues
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-24 18:57:53 +05:00
kashaf-ansari-dev
a40814a3ab mobile: revise app shortcut handling implementation
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-24 18:57:53 +05:00
kashaf-ansari-dev
180c202c4e mobile: handle app shortcuts correctly during cold and warm app launches
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-24 18:57:53 +05:00
kashaf-ansari-dev
64943647f8 mobile: add app shortcut for creating reminders
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-24 18:57:53 +05:00
Ammar Ahmed
4f816b47ba ci: fix ios build entitlements (#10146) 2026-07-24 18:55:11 +05:00
01zulfi
1071249baa desktop: fix app not appearing in alt-tab switcher (#9764)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-24 13:05:59 +05:00
Ammar Ahmed
d4775ad340 Merge pull request #10133 from streetwriters/chore/update-workflows
ci: publish ios preview app on linux runner
2026-07-24 12:10:35 +05:00
Chloe Oletto
0d8c855cd0 desktop: If --hidden passed via cli and any system tray option enabled, don't show the window. streetwriters/notesnook#9902 flathub/com.Notesnook.notesnook#99 (#9942)
Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>
2026-07-24 09:16:17 +05:00
Ammar Ahmed
d63ce5a213 Merge pull request #10126 from streetwriters/mobile-release/3.4.7
mobile: release v3.4.7
2026-07-22 11:12:45 +05:00
Ammar Ahmed
a1d6bb91e5 ci: fix ci workflow 2026-07-22 10:53:18 +05:00
Ammar Ahmed
a20f6960d7 ci: fix workflow 2026-07-22 10:45:49 +05:00
Ammar Ahmed
6b2b224f41 ci: fix ios workflow 2026-07-22 10:37:31 +05:00
Ammar Ahmed
054a023142 ci: fix intermittent ios preview build failure 2026-07-22 10:13:16 +05:00
Ammar Ahmed
441a6396be ci: publish ios preview app on linux runner 2026-07-22 10:02:18 +05:00
Chloe Oletto
987671e59c web: fix web-clipper crash on clipped file. (#10127)
Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>
2026-07-21 12:45:13 +05:00
Ammar Ahmed
0fc705ce1e Merge pull request #10130 from streetwriters/chore/update-workflows
ci: update mobile workflows
2026-07-21 12:41:02 +05:00
Ammar Ahmed
b3805534d6 ci: remove authorize step in android/ios preview builds 2026-07-21 12:35:08 +05:00
Ammar Ahmed
77c009e83d mobile: fix toast message shows under the sheet in global context 2026-07-21 12:14:08 +05:00
Ammar Ahmed
c9f1cba702 ci: update mobile workflows 2026-07-21 11:36:23 +05:00
Ammar Ahmed
637cf914ee mobile: fix app gets stuck after deleting history item on iOS 2026-07-21 10:25:12 +05:00
Ammar Ahmed
64877c519b Merge pull request #10128 from streetwriters/chore/update-workflows
ci: update workflows to use actions/checkout@v5
2026-07-21 10:04:20 +05:00
Ammar Ahmed
229b620473 ci: update workflows to use actions/checkout@v5 2026-07-21 09:27:49 +05:00
Ammar Ahmed
658e915589 mobile: release v3.4.7 2026-07-20 13:12:20 +05:00
Ammar Ahmed
d4658aa032 Merge pull request #10095 from kashaf-ansari-dev/fix-reminder-notification-permission
Improve the Android reminder notification permission flow by properly handling notification and exact alarm permissions.
2026-07-17 09:10:57 +05:00
Ammar Ahmed
489c793806 Merge pull request #10041 from kashaf-ansari-dev/feat/9723-clear-note-history
mobile: add option to clear note version history
2026-07-15 09:52:04 +05:00
kashaf-ansari-dev
eeae993736 mobile: add version history deletion strings
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-15 09:49:41 +05:00
kashaf-ansari-dev
c8cf4ce52a mobile: fix individual note history version deletion
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-15 09:49:41 +05:00
kashaf-ansari-dev
f6c31d19d8 mobile: add missing context prop in dialog
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-15 09:49:41 +05:00
kashaf-ansari-dev
a94e8d6852 mobile: render clear-history dialog with local context
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-15 09:49:41 +05:00
kashaf-ansari-dev
2edbc3bf2e mobile: remove clear version history action for multiple selected notes
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-15 09:49:41 +05:00
kashaf-ansari-dev
e4a7d63e07 mobile: add option to clear note version history
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-15 09:49:30 +05:00
Ammar Ahmed
76fd61af4f Merge pull request #10085 from streetwriters/fix/651
mobile: fix unable to change plan on buy-plan screen
2026-07-14 08:43:53 +05:00
Ammar Ahmed
98386a6d71 Merge pull request #10094 from streetwriters/mobile-release/3.4.6
mobile: release v3.4.6
2026-07-13 22:44:13 +05:00
kashaf-ansari-dev
539a630f77 mobile: improve reminder permission settings flow on Android
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-13 18:07:40 +05:00
kashaf-ansari-dev
6b65f8031d mobile: fix reminder creation after granting notification permission
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-13 14:58:31 +05:00
01zulfi
3c85cddfe7 Merge pull request #10093 from streetwriters/release/3.4.4
web: bump version to 3.4.4
2026-07-13 14:14:25 +05:00
Ammar Ahmed
e426426468 mobile: release v3.4.6 2026-07-13 12:56:49 +05:00
01zulfi
109e46bd06 web: bump version to 3.4.4
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-13 12:28:07 +05:00
Ammar Ahmed
281a973c37 mobile: fix unable to change plan on buy-plan screen 2026-07-11 12:00:15 +05:00
Ammar Ahmed
2738207c20 Merge pull request #9998 from kashaf-ansari-dev/fix-9967-linking-note-blocks-loading-state
mobile: prevent upgrade prompt flash in note block linking list
2026-07-10 18:41:04 +05:00
01zulfi
1aa73c1f23 web: update editor's font size dynamically when default font size setting changes (#10047)
Signed-off-by: 01zulfi <85733202+01zulfi@users.noreply.github.com>
2026-07-10 09:39:21 +05:00
Masatoshi Ogiwara
9ec22724e9 fix: typo in some documents (#10071) 2026-07-09 10:34:09 +05:00
Kashaf Ansari
4a7aa27bd9 mobile: add keep-screen-on preference in settings-behaviour (#10043)
* mobile: add keep-screen-on preference in settings-behaviour

Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>

* mobile: default Keep Screen On to true and clarify description

Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>

---------

Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-09 09:49:38 +05:00
kashaf-ansari-dev
6051614d96 mobile: add bottom spacing for create link button
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-08 18:23:23 +05:00
kashaf-ansari-dev
295a461dda mobile: wire upgrade plan button to paywall on link-note
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-07-08 17:55:00 +05:00
Chloe Oletto
a4cbe83211 vericrypt: Update to support DEK. (#10005)
* vericrypt: Update to support DEK.

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* vericrypt: Clear account data on restart.

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* Update apps/vericrypt/src/components/decrypted-result.tsx

Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* docs: Update data encryption documentation to be accurate with the current client versions.

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* docs: Explain what happens better.

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* Fix typo in encryption key generation section

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* Update docs/help/contents/how-is-my-data-encrypted.md

Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

* docs: update wording

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>

---------

Signed-off-by: Chloe Oletto <NeedsChloesure@riseup.net>
Co-authored-by: Abdullah Atta <thecodrr@protonmail.com>
2026-07-08 10:24:17 +05:00
Masatoshi Ogiwara
afd63db043 docs(help): typo in documents (#10064) 2026-07-08 08:41:17 +05:00
kashaf-ansari-dev
e0b9932aba mobile: prevent upgrade prompt flash in note block linking list
Signed-off-by: kashaf-ansari-dev <kashafansari3108@gmail.com>
2026-06-19 22:31:56 +05:00
557 changed files with 22214 additions and 4848 deletions

View File

@@ -10,7 +10,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Enable KVM
run: |

View File

@@ -0,0 +1,104 @@
name: Notesnook Android Preview Build
# UNTRUSTED stage. Runs on `pull_request`, so fork code is checked out and
# compiled with a read-only GITHUB_TOKEN and NO repository secrets. The release
# APK is built without any signing secret (the same as before). Firebase
# distribution and the PR comment happen in android.preview.publish.yml, which
# runs in the trusted `workflow_run` context and never executes fork code.
# Because no secrets are exposed here, the build runs automatically for every
# PR (including forks) with no authorization gate.
on:
pull_request:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "apps/mobile/**"
- "packages/**"
- ".github/workflows/android.preview.build.yml"
- ".github/workflows/android.preview.publish.yml"
concurrency:
group: android-preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-22.04
env:
STAGING_BUILD: true
steps:
- name: Checkout PR code
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Use specific Java version for the builds
uses: joschi/setup-jdk@v2
with:
java-version: "17"
architecture: "x64"
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
with:
tool-cache: false
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: true
- name: Install node modules
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=mobile
- name: Make Gradlew Executable
run: cd apps/mobile/android && chmod +x ./gradlew
- name: Get build number
id: build-number
run: echo "timestamp=$(($(date +%s) - 1774851180 ))" >> $GITHUB_OUTPUT
- name: Check for typescript errors
run: |
npm run tx mobile:build
cd apps/mobile
npx tsc --noEmit
- name: Build arm64-v8a apk
run: |
cd apps/mobile/android
./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a -PstagingReleaseBuild=true -PprBuildNumber=${{ steps.build-number.outputs.timestamp }}
- name: Stage build artifact
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/artifact"
cp apps/mobile/android/app/build/outputs/apk/release/app-arm64-v8a-release.apk \
"$RUNNER_TEMP/artifact/app-preview.apk"
{
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
echo "HEAD_SHA=${{ github.event.pull_request.head.sha }}"
} > "$RUNNER_TEMP/artifact/pr-meta.env"
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: android-preview-build
path: ${{ runner.temp }}/artifact
if-no-files-found: error
retention-days: 1
- name: Upload sourcemaps
uses: actions/upload-artifact@v4
with:
name: sourcemaps
path: |
apps/mobile/android/app/build/generated/sourcemaps/**/*.map

View File

@@ -1,135 +0,0 @@
name: Notesnook Android Preview
on:
pull_request_target:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "apps/mobile/**"
- "packages/**"
- ".github/workflows/android.preview.firebase.yml"
jobs:
authorize:
environment: ${{ github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name != github.repository &&
'external' || 'internal' }}
runs-on: ubuntu-latest
steps:
- run: echo true
build:
needs: authorize
runs-on: ubuntu-22.04
env:
STAGING_BUILD: true
steps:
- name: Checkout
uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Use specific Java version for the builds
uses: joschi/setup-jdk@v2
with:
java-version: "17"
architecture: "x64"
- name: Free Disk Space (Ubuntu)
uses: jlumbroso/free-disk-space@main
with:
# this might remove tools that are actually needed,
# if set to "true" but frees about 6 GB
tool-cache: false
# all of these default to true, but feel free to set to
# "false" if necessary for your workflow
android: false
dotnet: true
haskell: true
large-packages: true
docker-images: true
swap-storage: true
- name: Install node modules
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=mobile
- name: Make Gradlew Executable
run: cd apps/mobile/android && chmod +x ./gradlew
- name: Get build number
id: build-number
run: echo "timestamp=$(($(date +%s) - 1774851180 ))" >> $GITHUB_OUTPUT
- name: Check for typescript errors
run: |
npm run tx mobile:build
cd apps/mobile
npx tsc --noEmit
- name: Build arm64-v8a apk
run: |
cd apps/mobile/android
./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a -PstagingReleaseBuild=true -PprBuildNumber=${{ steps.build-number.outputs.timestamp }}
- name: Get app version
id: package-version
uses: saionaro/extract-package-version@master
with:
path: apps/mobile
- name: Publish to firebase CLI
id: firebase-output
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{secrets.FIREBASE_APP_ID}}
serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
groups: testers
file: apps/mobile/android/app/build/outputs/apk/release/app-arm64-v8a-release.apk
releaseNotes: Preview for https://github.com/streetwriters/notesnook/pull/${{github.event.number}}
- name: Post or update PR comment
uses: actions/github-script@v6
env:
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
with:
script: |
const marker = '<!-- android-preview-comment -->';
const prNumber = context.issue.number;
const previewUrl = process.env.preview_url || '';
const body = `${marker}\n**Android App Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.GITHUB_SHA}\n`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
}
- name: Upload sourcemaps
uses: actions/upload-artifact@v4
with:
name: sourcemaps
path: |
apps/mobile/android/app/build/generated/sourcemaps/**/*.map

View File

@@ -0,0 +1,72 @@
name: Notesnook Android Preview Publish
# TRUSTED stage. Runs via `workflow_run` after the build workflow finishes, so
# it has the base repo's secrets and a write-scoped token. It downloads the APK
# the build produced and distributes it + posts the PR comment. It never checks
# out or executes fork code.
on:
workflow_run:
workflows: ["Notesnook Android Preview Build"]
types: [completed]
jobs:
publish:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: android-preview-build
path: ${{ runner.temp }}/artifact
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Load PR metadata
run: cat "$RUNNER_TEMP/artifact/pr-meta.env" >> "$GITHUB_ENV"
- name: Publish to Firebase
id: firebase-output
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{ secrets.FIREBASE_APP_ID }}
serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
groups: testers
file: ${{ runner.temp }}/artifact/app-preview.apk
releaseNotes: Preview for https://github.com/${{ github.repository }}/pull/${{ env.PR_NUMBER }}
- name: Post or update PR comment
uses: actions/github-script@v7
env:
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
with:
script: |
const marker = '<!-- android-preview-comment -->';
const prNumber = Number(process.env.PR_NUMBER);
if (!prNumber) return;
const previewUrl = process.env.preview_url || '';
const body = `${marker}\n**Android App Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.HEAD_SHA}\n`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
}

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -35,7 +35,7 @@ jobs:
needs: authorize
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -56,7 +56,7 @@ jobs:
macos-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -127,7 +127,7 @@ jobs:
linux-x64-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -171,7 +171,7 @@ jobs:
linux-arm64-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -215,7 +215,7 @@ jobs:
windows-artifact-url: ${{ steps.artifact-upload-step.outputs.artifact-url }}
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -142,7 +142,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -246,7 +246,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -314,7 +314,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -378,7 +378,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup .NET Core SDK
uses: actions/setup-dotnet@v2

View File

@@ -24,7 +24,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -58,7 +58,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -92,7 +92,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
@@ -126,7 +126,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

79
.github/workflows/help.preview.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: Notesnook Help PR Preview
on:
pull_request:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "docs/help/**"
# re-run workflow if workflow file changes
- ".github/workflows/help.preview.yml"
jobs:
build-and-deploy:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-latest
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Install dependencies
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=help
- name: Build help
run: npm run build:help
- name: Deploy to Cloudflare
id: deploy
working-directory: ./docs/help
run: |
set -euo pipefail
DEPLOY_OUT=$(npx --yes wrangler versions upload 2>&1) || { echo "$DEPLOY_OUT"; exit 1; }
echo "$DEPLOY_OUT"
PREVIEW_URL=$(printf "%s" "$DEPLOY_OUT" | grep -Eo 'https?://[^ ]+' | head -1 || true)
if [ -z "$PREVIEW_URL" ]; then
echo "WARNING: could not parse preview URL from wrangler output"
fi
echo "preview_url=$PREVIEW_URL" >> $GITHUB_ENV
- name: Post or update PR comment
uses: actions/github-script@v6
env:
preview_url: ${{ env.preview_url }}
with:
script: |
const marker = '<!-- docs-pages-preview-comment -->';
const prNumber = context.issue.number;
const previewUrl = process.env.preview_url || '';
const body = `${marker}\n**Cloudflare Pages Docs Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.GITHUB_SHA}\n`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
}

View File

@@ -2,11 +2,6 @@ name: Publish Notesnook Help
on:
workflow_dispatch:
push:
branches:
- "master"
paths:
- "docs/help/**"
jobs:
build:
@@ -15,16 +10,20 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
# VitePress reads git history to show the last updated date per page.
fetch-depth: 0
- name: Install docgen
run: cargo install --git https://github.com/thecodrr/docgen
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Build site
run: docgen build --release
working-directory: docs/help
- name: Install dependencies
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=help
- name: Build help
run: npm run build:help
- name: Setup environment
run: |
@@ -32,4 +31,4 @@ jobs:
echo "CLOUDFLARE_API_TOKEN=${{ secrets.CLOUDFLARE_API_TOKEN }}" >> $GITHUB_ENV
- name: Publish on Cloudflare Pages
run: npx --yes wrangler pages deploy --project-name notesnook-help ./docs/help/site/ --branch main
run: npx --yes wrangler deploy

152
.github/workflows/ios.preview.build.yml vendored Normal file
View File

@@ -0,0 +1,152 @@
name: Notesnook iOS Preview Build
# UNTRUSTED stage. Runs on `pull_request`, so fork code is checked out and
# compiled with a read-only GITHUB_TOKEN and NO repository secrets. It only
# produces an *unsigned* archive. Signing, Firebase distribution and PR
# comments happen in ios.preview.publish.yml, which runs in the trusted
# `workflow_run` context and never executes fork code. Because no secrets are
# exposed here, the build runs automatically for every PR (including forks)
# with no authorization gate.
on:
pull_request:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "apps/mobile/**"
- "packages/**"
- ".github/workflows/ios.preview.build.yml"
- ".github/workflows/ios.preview.publish.yml"
# A fork that spams pushes shouldn't queue up macOS builds.
concurrency:
group: ios-preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
build:
runs-on: macos-26
timeout-minutes: 60
steps:
- name: Checkout PR code
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.1.1"
- name: Setup iOS Platform
run: |
# GitHub runs this as `/bin/bash -e`, so `set +e` is required -- without
# it the first failure aborts the step before any retry can happen.
# Both commands hit "Unable to connect to simulator" (exit 70) when
# CoreSimulatorService is wedged, so retry each and bounce it between.
set +e
DL_DIR="$RUNNER_TEMP/ios-platform"
retry() {
for i in 1 2 3; do
"$@" && return 0
echo "Attempt $i failed ($*); resetting CoreSimulator..."
sudo killall -9 com.apple.CoreSimulator.CoreSimulatorService simdiskimaged 2>/dev/null
sleep 20
xcrun simctl list runtimes >/dev/null 2>&1
done
echo "::error::Failed after 3 attempts: $*"
exit 1
}
xcrun simctl list runtimes >/dev/null 2>&1
retry xcodebuild -downloadPlatform iOS -exportPath "$DL_DIR"
# Find the dmg; its name embeds a build number that changes.
DMG="$(find "$DL_DIR" -name '*.dmg' | head -n1)"
[ -n "$DMG" ] || { echo "::error::No dmg in $DL_DIR"; exit 1; }
retry xcodebuild -importPlatform "$DMG"
- name: Install node modules
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=mobile
- name: Build packages
run: npm run tx @notesnook/mobile:build
- name: Cache Pods
uses: actions/cache@v4
id: pods-cache
with:
path: apps/mobile/ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('apps/mobile/ios/Podfile.lock') }}
- name: Install Pods
run: |
cd apps/mobile/ios
pod install
- name: Check for typescript errors
run: |
npm run tx mobile:build
cd apps/mobile
npx tsc --noEmit
- name: Get marketing version
id: marketing-version
run: echo "version=$(grep "IOS_MARKETING_VERSION" apps/mobile/ios/build-configs/ios-build.staging.xcconfig | awk -F'=' '{print $2}' | xargs)" >> $GITHUB_OUTPUT
- name: Get build number
id: build-number
run: echo "timestamp=$(($(date +%s) - 1774851180 ))" >> $GITHUB_OUTPUT
- name: Make staging xcconfig active
run: |
cd apps/mobile/ios/build-configs
./use-ios-build-config.sh staging --marketing-version ${{steps.marketing-version.outputs.version}} --build-number ${{steps.build-number.outputs.timestamp}}
# Archive unsigned: this job has no certs/profiles (secrets), and the iOS
# device SDK forbids ad-hoc signing, so real signing happens in the trusted
# publish workflow (ios.preview.publish.yml). Do NOT pass
# CODE_SIGN_ENTITLEMENTS="" -- each target must keep its own entitlements so
# the archive records them; blanking them dropped the App Group and crashed
# MMKV on launch.
- name: Archive (unsigned)
run: |
set -euo pipefail
xcodebuild \
-workspace apps/mobile/ios/Notesnook.xcworkspace \
-scheme Notesnook \
-configuration Release \
-sdk iphoneos \
-destination 'generic/platform=iOS' \
-archivePath "$RUNNER_TEMP/Notesnook.xcarchive" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" \
archive
- name: Stage build artifact
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/artifact"
tar -czf "$RUNNER_TEMP/artifact/Notesnook.xcarchive.tar.gz" \
-C "$RUNNER_TEMP" Notesnook.xcarchive
# Carry the PR context forward; workflow_run cannot see it reliably for forks.
{
echo "PR_NUMBER=${{ github.event.pull_request.number }}"
echo "HEAD_SHA=${{ github.event.pull_request.head.sha }}"
} > "$RUNNER_TEMP/artifact/pr-meta.env"
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: ios-preview-build
path: ${{ runner.temp }}/artifact
if-no-files-found: error
retention-days: 1

View File

@@ -1,173 +0,0 @@
name: Notesnook iOS Preview
on:
pull_request_target:
types: [opened, reopened, synchronize]
branches: [master, beta]
paths:
- "apps/mobile/**"
- "packages/**"
- ".github/workflows/ios.preview.firebase.yml"
jobs:
authorize:
environment: ${{ github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.full_name != github.repository &&
'external' || 'internal' }}
runs-on: ubuntu-latest
steps:
- run: echo true
build:
needs: authorize
runs-on: macos-26
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
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
npm run bootstrap -- --scope=mobile
- name: Build packages
run: npm run tx @notesnook/mobile:build
- name: Cache Pods
uses: actions/cache@v3
id: pods-cache
with:
path: apps/mobile/ios/Pods
key: ${{ runner.os }}-pods-${{ hashFiles('apps/mobile/ios/Podfile.lock') }}
- name: Install Pods
run: |
cd apps/mobile/ios
pod install
- name: Check for typescript errors
run: |
npm run tx mobile:build
cd apps/mobile
npx tsc --noEmit
- name: Get marketing version
id: marketing-version
run: echo "version=$(grep "IOS_MARKETING_VERSION" apps/mobile/ios/build-configs/ios-build.staging.xcconfig | awk -F'=' '{print $2}' | xargs)" >> $GITHUB_OUTPUT
- name: Get build number
id: build-number
run: echo "timestamp=$(($(date +%s) - 1774851180 ))" >> $GITHUB_OUTPUT
- name: Make staging xcconfig active
run: |
cd apps/mobile/ios/build-configs
./use-ios-build-config.sh staging --marketing-version ${{steps.marketing-version.outputs.version}} --build-number ${{steps.build-number.outputs.timestamp}}
- name: Build iOS App
uses: ammarahm-ed/ios-build-action@master
with:
bundle-identifier: org.streetwriters.notesnook
scheme: Notesnook
configuration: "Release"
export-options: apps/mobile/ios/ExportOptionsStaging.plist
export-method: "ad-hoc"
project-path: apps/mobile/ios/Notesnook.xcodeproj
workspace-path: apps/mobile/ios/Notesnook.xcworkspace
update-targets: |
Notesnook
Make Note
NotesWidgetExtension
disable-targets: Notesnook-tvOS,Notesnook-tvOSTests,NotesnookTests
code-signing-identity: Apple Distribution
team-id: ${{ secrets.APPLE_TEAM_ID }}
p12-base64: ${{ secrets.APPLE_CERTIFICATE_P12 }}
certificate-password: ${{ secrets.APPLE_CERTIFICATE_P12_PASSWORD }}
app-store-connect-api-key-issuer-id: ${{ secrets.API_KEY_ISSUER_ID }}
app-store-connect-api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
app-store-connect-api-key-base64: ${{ secrets.APPSTORE_CONNECT_API_KEY_BASE64 }}
output-path: Notesnook.ipa
mobileprovision-base64: |
${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_APP }}
${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_SHARE }}
${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_WIDGET }}
- name: Upload IPA artifact
uses: actions/upload-artifact@v4
with:
name: ios-preview-ipa
path: Notesnook.ipa
if-no-files-found: error
retention-days: 1
publish:
runs-on: ubuntu-latest
needs: build
timeout-minutes: 20
steps:
- name: Download IPA artifact
uses: actions/download-artifact@v4
with:
name: ios-preview-ipa
path: .
- name: Publish to firebase CLI
id: firebase-output
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{secrets.FIREBASE_IOS_APP_ID}}
serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
groups: testers
file: Notesnook.ipa
releaseNotes: Preview for https://github.com/streetwriters/notesnook/pull/${{github.event.number}}
- name: Post or update PR comment
uses: actions/github-script@v6
env:
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
with:
script: |
const marker = '<!-- ios-preview-comment -->';
const prNumber = context.issue.number;
const previewUrl = process.env.preview_url || '';
const body = `${marker}\n**iOS App Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.GITHUB_SHA}\n`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
}

View File

@@ -0,0 +1,222 @@
name: Notesnook iOS Preview Publish
# TRUSTED stage. Runs via `workflow_run` after the build workflow finishes, so
# it has the base repo's secrets and a write-scoped token. It checks out the
# BASE repository (never fork code), downloads the unsigned archive the build
# produced, then signs + distributes it and posts the PR comment. Signing is
# done directly with `codesign` on the prebuilt archive -- no app build phases
# run here, so fork code is never executed in this trusted context.
#
# Split into two jobs on purpose: signing needs macOS, but the Firebase
# distribution action is a Docker container action and only runs on Linux.
on:
workflow_run:
workflows: ["Notesnook iOS Preview Build"]
types: [completed]
jobs:
export:
# Only publish previews for builds that actually succeeded.
if: github.event.workflow_run.conclusion == 'success'
runs-on: macos-26
timeout-minutes: 30
outputs:
pr_number: ${{ steps.meta.outputs.PR_NUMBER }}
head_sha: ${{ steps.meta.outputs.HEAD_SHA }}
steps:
- name: Checkout base repo (trusted)
uses: actions/checkout@v5
# No `ref` -> checks out the default branch, i.e. trusted base code.
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: "26.1.1"
- name: Download build artifact
uses: actions/download-artifact@v4
with:
name: ios-preview-build
path: ${{ runner.temp }}/artifact
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Load PR metadata & extract archive
id: meta
run: |
set -euo pipefail
cat "$RUNNER_TEMP/artifact/pr-meta.env" >> "$GITHUB_OUTPUT"
tar -xzf "$RUNNER_TEMP/artifact/Notesnook.xcarchive.tar.gz" -C "$RUNNER_TEMP"
- name: Import signing certificate
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE_P12 }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_P12_PASSWORD }}
- name: Install provisioning profiles
env:
PROFILE_APP: ${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_APP }}
PROFILE_SHARE: ${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_SHARE }}
PROFILE_WIDGET: ${{ secrets.APPLE_MOBILE_PROVISION_ADHOC_WIDGET }}
run: |
set -euo pipefail
PROFILE_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"
mkdir -p "$PROFILE_DIR"
i=0
for p in "$PROFILE_APP" "$PROFILE_SHARE" "$PROFILE_WIDGET"; do
echo "$p" | base64 --decode > "$PROFILE_DIR/preview-$i.mobileprovision"
i=$((i + 1))
done
# Sign the unsigned archive by hand. `xcodebuild -exportArchive` re-signs
# from the archive's `archived-expanded-entitlements.xcent`, which an
# unsigned archive does not contain, so it dropped the App Group and the
# app crashed on launch. Instead we sign each bundle directly, taking the
# entitlements from each provisioning profile (exactly what Apple
# provisioned -- App Group, keychain groups with the right team prefix).
# Runs on macOS but executes NO fork build phases, so it stays trusted.
- name: Sign IPA
run: |
set -euo pipefail
PROFILES="$HOME/Library/MobileDevice/Provisioning Profiles"
APP="$(find "$RUNNER_TEMP/Notesnook.xcarchive/Products/Applications" -maxdepth 1 -name '*.app' | head -n1)"
[ -n "$APP" ] || { echo "::error::No .app in archive"; exit 1; }
# Single Apple Distribution identity from the imported keychain.
IDENTITY="$(security find-identity -v -p codesigning | awk '/Apple Distribution/{print $2; exit}')"
[ -n "$IDENTITY" ] || { echo "::error::No Apple Distribution identity found"; exit 1; }
echo "Signing identity: $IDENTITY"
TMP="$RUNNER_TEMP/sign"; mkdir -p "$TMP"
# Decode a profile's bundle id (application-identifier minus team prefix).
profile_bundle_id() {
security cms -D -i "$1" -o "$TMP/_p.plist" 2>/dev/null
local appid; appid="$(plutil -extract Entitlements.application-identifier raw -o - "$TMP/_p.plist")"
echo "${appid#*.}"
}
# Find the profile matching a bundle id; echoes its path.
profile_for() {
local want="$1" p
for p in "$PROFILES"/*.mobileprovision; do
[ -e "$p" ] || continue
[ "$(profile_bundle_id "$p")" = "$want" ] && { echo "$p"; return 0; }
done
return 1
}
# Embed the matching profile and sign a bundle with the profile's entitlements.
sign_bundle() {
local bundle="$1" bid prof ent
bid="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$bundle/Info.plist")"
prof="$(profile_for "$bid")" || { echo "::error::No provisioning profile for $bid"; exit 1; }
ent="$TMP/ent-$bid.plist"
security cms -D -i "$prof" -o "$TMP/_p.plist"
plutil -extract Entitlements xml1 -o "$ent" "$TMP/_p.plist"
cp "$prof" "$bundle/embedded.mobileprovision"
echo "Signing $(basename "$bundle") ($bid)"
codesign --force --sign "$IDENTITY" --entitlements "$ent" "$bundle"
}
# Sign inside-out: nested code first, then extensions, then the app.
if [ -d "$APP/Frameworks" ]; then
find "$APP/Frameworks" -maxdepth 1 \( -name '*.framework' -o -name '*.dylib' \) -print0 \
| while IFS= read -r -d '' f; do codesign --force --sign "$IDENTITY" "$f"; done
fi
if [ -d "$APP/PlugIns" ]; then
for appex in "$APP/PlugIns"/*.appex; do
[ -e "$appex" ] || continue
if [ -d "$appex/Frameworks" ]; then
find "$appex/Frameworks" -maxdepth 1 \( -name '*.framework' -o -name '*.dylib' \) -print0 \
| while IFS= read -r -d '' f; do codesign --force --sign "$IDENTITY" "$f"; done
fi
sign_bundle "$appex"
done
fi
sign_bundle "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
# Package into an IPA.
rm -rf "$TMP/Payload"; mkdir -p "$TMP/Payload"
cp -R "$APP" "$TMP/Payload/"
( cd "$TMP" && zip -qry "$RUNNER_TEMP/Notesnook.ipa" Payload )
# Fail fast if the App Group didn't make it into the signed app -- its
# absence is the launch crash (MMKV nil group path). Never distribute
# a build without it.
echo "::group::Signed app entitlements"
codesign -d --entitlements :- "$APP" 2>/dev/null || true
echo "::endgroup::"
codesign -d --entitlements :- "$APP" 2>/dev/null | grep -q "group.org.streetwriters.notesnook" \
|| { echo "::error::Signed app is missing the App Group entitlement; it would crash on launch."; exit 1; }
echo "App Group entitlement present."
- name: Upload signed IPA
uses: actions/upload-artifact@v4
with:
name: ios-preview-ipa
path: ${{ runner.temp }}/Notesnook.ipa
if-no-files-found: error
retention-days: 1
# Must be Linux: wzieba/Firebase-Distribution-Github-Action is a Docker
# container action, which GitHub only supports on Linux runners.
distribute:
needs: export
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Download signed IPA
uses: actions/download-artifact@v4
with:
name: ios-preview-ipa
path: ${{ runner.temp }}
- name: Publish to Firebase
id: firebase-output
uses: wzieba/Firebase-Distribution-Github-Action@v1
with:
appId: ${{ secrets.FIREBASE_IOS_APP_ID }}
serviceCredentialsFileContent: ${{ secrets.QA_SERVICE_ACCOUNT }}
groups: testers
file: ${{ runner.temp }}/Notesnook.ipa
releaseNotes: Preview for https://github.com/${{ github.repository }}/pull/${{ needs.export.outputs.pr_number }}
- name: Post or update PR comment
uses: actions/github-script@v7
env:
preview_url: ${{ steps.firebase-output.outputs.TESTING_URI }}
PR_NUMBER: ${{ needs.export.outputs.pr_number }}
HEAD_SHA: ${{ needs.export.outputs.head_sha }}
with:
script: |
const marker = '<!-- ios-preview-comment -->';
const prNumber = Number(process.env.PR_NUMBER);
if (!prNumber) return;
const previewUrl = process.env.preview_url || '';
const body = `${marker}\n**iOS App Preview**\n\n${previewUrl || 'Preview URL unavailable — check workflow logs.'}\n\nCommit: ${process.env.HEAD_SHA}\n`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const existing = comments.find(c => c.body && c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body,
});
}

View File

@@ -9,7 +9,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -22,7 +22,7 @@ jobs:
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -22,7 +22,7 @@ jobs:
id-token: write
steps:
- name: Check out the repo
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Collect package metadata
id: package_metadata

View File

@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -19,7 +19,7 @@ jobs:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
@@ -27,7 +27,9 @@ jobs:
uses: ./.github/actions/setup-node-with-cache
- name: Install dependencies
run: npm ci
run: |
npm ci --ignore-scripts --prefer-offline --no-audit
npm run bootstrap -- --scope=web
- name: Build web
run: npm run build:web

View File

@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup Node
uses: ./.github/actions/setup-node-with-cache

View File

@@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}
@@ -66,7 +66,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
ref: ${{ github.event.pull_request.head.sha || github.ref }}

View File

@@ -65,7 +65,7 @@ We take all queries, issues and bug reports that you might have. Feel free to as
## Additional Resources
- [Migrating & Importing your data from other apps — Importer](https://help.notesnook.com/importing-notes)
- [Migrating & Importing your data from other apps — Importer](https://notesnook.com/help/importing-notes)
- [Privacy policy](https://notesnook.com/privacy) & [Terms of service](https://notesnook.com/terms)
- [Verify Notesnook encryption claims yourself — Vericrypt](https://vericrypt.notesnook.com/)
- [Why Notesnook requires an email address?](https://blog.notesnook.com/why-notesnook-requires-an-email-address/)

View File

@@ -42,6 +42,7 @@ export interface AppContext {
export interface TestOptions {
version: string;
args?: string[];
config?: Record<string, unknown>;
}
@@ -72,7 +73,8 @@ export async function buildAndLaunchApp(
const { app } = await launchApp(
executablePath,
userDataDir,
options?.version
options?.version,
options?.args
);
const ctx: AppContext = {
app,
@@ -82,7 +84,8 @@ export async function buildAndLaunchApp(
const { app } = await launchApp(
executablePath,
userDataDir,
options?.version
options?.version,
options?.args
);
ctx.app = app;
ctx.userDataDir = userDataDir;
@@ -94,11 +97,12 @@ export async function buildAndLaunchApp(
async function launchApp(
executablePath: string,
userDataDir: string,
version?: string
version?: string,
args: string[] = []
) {
const app = await electron.launch({
executablePath,
args: IS_DEBUG ? [] : ["--hidden"],
args: IS_DEBUG ? [...args] : ["--hidden", ...args],
baseURL: "https://app.notesnook.com",
acceptDownloads: true,
env: {
@@ -154,6 +158,7 @@ export async function buildApp(version?: string) {
stdio: IS_DEBUG ? "inherit" : "ignore",
env: {
...process.env,
CSC_IDENTITY_AUTO_DISCOVERY: "false",
NOTESNOOK_STAGING: "true",
NN_PRODUCT_NAME: productName,
NN_APP_ID: `com.notesnook.test.${productName}`,

View File

@@ -17,7 +17,65 @@ 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 "@nn/test";
import { test, expect } from "@nn/test";
import type { ElectronApplication } from "@playwright/test";
async function getMainWindowState(app: ElectronApplication) {
return await app.evaluate((window) => {
const { BrowserWindow } = window;
const mainWindow = BrowserWindow.getAllWindows()[0];
if (!mainWindow) throw new Error("Main window not found");
return {
isMinimized: mainWindow.isMinimized(),
isVisible: mainWindow.isVisible()
};
});
}
test("make sure app loads", async ({ page }) => {
await page.waitForSelector(".ProseMirror");
});
test("hidden launch minimizes when tray is disabled", async ({
launchElectronApp,
options
}) => {
const app = await launchElectronApp({
version: options.version,
args: ["--hidden"],
config: {
desktopSettings: {
minimizeToSystemTray: false,
closeToSystemTray: false
}
}
});
const page = await app.firstWindow();
await page.waitForSelector(".ProseMirror");
const state = await getMainWindowState(app);
expect(state.isMinimized).toBe(true);
});
test("hidden launch does not minimize when close-to-tray is enabled", async ({
launchElectronApp,
options
}) => {
const app = await launchElectronApp({
version: options.version,
args: ["--hidden"],
config: {
desktopSettings: {
minimizeToSystemTray: false,
closeToSystemTray: true
}
}
});
const page = await app.firstWindow();
await page.waitForSelector(".ProseMirror");
const state = await getMainWindowState(app);
expect(state.isMinimized).toBe(false);
});

View File

@@ -203,11 +203,12 @@ module.exports = {
toolsets: {
appimage: "1.0.2"
},
snap: {
autoStart: false,
confinement: "strict",
allowNativeWayland: true,
base: "core22"
snapcraft: {
base: "core24",
core24: {
confinement: "strict",
autoStart: false
}
},
extraResources: ["app-update.yml", "./assets/**"],
extraMetadata: {

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.4.3",
"version": "3.4.6",
"appAppleId": "1544027013",
"private": true,
"main": "./dist/cjs/index.js",

View File

@@ -92,6 +92,17 @@ async function createWindow() {
const cliOptions = await parseArguments(process.argv);
setTheme(getTheme());
// this workaround is necessary because macos doesn't support
// the --hidden flag when launching the app on startup
if (
process.platform === "darwin" &&
app.getLoginItemSettings().wasOpenedAtLogin &&
config.desktopSettings.autoStart &&
config.desktopSettings.startMinimized
) {
cliOptions.hidden = true;
}
const mainWindowState = new WindowState({});
const mainWindow = new BrowserWindow({
show: !cliOptions.hidden,
@@ -141,7 +152,13 @@ async function createWindow() {
mainWindow.setMenuBarVisibility(false);
mainWindowState.manage(mainWindow);
if (cliOptions.hidden && !config.desktopSettings.minimizeToSystemTray)
if (
cliOptions.hidden &&
!(
config.desktopSettings.minimizeToSystemTray ||
config.desktopSettings.closeToSystemTray
)
)
mainWindow.minimize();
await mainWindow.webContents.loadURL(`${createURL(cliOptions, "/")}`);
@@ -164,6 +181,14 @@ async function createWindow() {
);
mainWindow.webContents.session.setProxy({ proxyRules: config.proxyRules });
mainWindow.on("show", () =>
/**
* We may set `skipTaskbar` to true at startup.
* This also removes the window from the Alt-Tab switcher.
* To fix that, whenever the app is shown, we set `skipTaskbar` to false.
*/
mainWindow.setSkipTaskbar(false)
);
mainWindow.once("closed", () => {
globalThis.window = null;
});

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v5
with:
persist-credentials: false

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v5
with:
persist-credentials: false

View File

@@ -140,7 +140,7 @@ android {
if (project.hasProperty("prBuildNumber")) {
versionCode Integer.parseInt(prBuildNumber())
} else {
versionCode 3111
versionCode 3116
}
versionName getNpmVersion()
testBuildType System.getProperty('testBuildType', 'debug')
@@ -233,6 +233,9 @@ dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0")
// Lets the widgets push their list data straight into the RemoteViews on every API level,
// instead of the deprecated RemoteViewsService adapter (which needs API 31 to do natively).
implementation("androidx.core:core-remoteviews:1.0.0")
implementation("androidx.core:core-splashscreen:1.0.0")
implementation 'androidx.multidex:multidex:2.0.1'

View File

@@ -86,8 +86,9 @@
android:name=".NotePreviewWidget"
android:exported="false"
android:label="@string/note">
<intent-filter>"
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
<action android:name="android.appwidget.action.APPWIDGET_RESTORED" />
</intent-filter>
<meta-data
@@ -95,6 +96,15 @@
android:resource="@xml/note_widget_info" />
</receiver>
<receiver
android:name=".WidgetTimeChangeReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter>
</receiver>
<receiver
android:name=".ReminderWidgetProvider"
android:exported="false"
@@ -230,11 +240,6 @@
</intent-filter>
</service>
<service
android:name=".ReminderViewsService"
android:exported="true"
android:permission="android.permission.BIND_REMOTEVIEWS" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"

View File

@@ -5,15 +5,11 @@ import android.appwidget.AppWidgetManager;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.util.Log;
import android.widget.RemoteViews;
import com.facebook.react.ReactActivity;
import com.facebook.react.ReactActivityDelegate;
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint;
import com.facebook.react.defaults.DefaultReactActivityDelegate;
import com.google.gson.Gson;
import com.streetwriters.notesnook.datatypes.Note;
public class NotePreviewConfigureActivity extends ReactActivity {
@@ -40,18 +36,32 @@ public class NotePreviewConfigureActivity extends ReactActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(null);
Intent intent = getIntent();
Bundle extras = intent.getExtras();
int appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID;
if (extras != null) {
appWidgetId = extras.getInt(
AppWidgetManager.EXTRA_APPWIDGET_ID,
AppWidgetManager.INVALID_APPWIDGET_ID);
NotePreviewConfigureActivity.appWidgetId = appWidgetId;
}
activity = this;
readAppWidgetId(getIntent());
}
/**
* We launch as singleTask, so configuring a second widget while this screen is still alive
* arrives here rather than in onCreate(). Without this the activity would keep writing to
* whichever widget it happened to be opened for first.
*/
@Override
public void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
activity = this;
readAppWidgetId(intent);
}
private void readAppWidgetId(Intent intent) {
Bundle extras = intent != null ? intent.getExtras() : null;
int appWidgetId = extras != null
? extras.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
: AppWidgetManager.INVALID_APPWIDGET_ID;
NotePreviewConfigureActivity.appWidgetId = appWidgetId;
Intent resultValue = new Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
setResult(Activity.RESULT_CANCELED, resultValue);
activity = this;
}
public static void saveAndFinish(Context context) {

View File

@@ -1,6 +1,5 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
@@ -8,51 +7,125 @@ import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.view.View;
import android.widget.RemoteViews;
import com.google.gson.Gson;
import com.streetwriters.notesnook.datatypes.Note;
import java.util.HashSet;
import java.util.Set;
public class NotePreviewWidget extends AppWidgetProvider {
static String OpenNoteId = "com.streetwriters.notesnook.OpenNoteId";
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager,
int appWidgetId) {
String data = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE).getString(String.valueOf(appWidgetId), "");
if (data.isEmpty()) {
String data = context.getSharedPreferences(WidgetUtils.PREFERENCES, Context.MODE_PRIVATE).getString(String.valueOf(appWidgetId), "");
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.note_widget);
Note note = WidgetUtils.parseNote(data);
if (note == null) {
// Either the widget was never configured, or we lost the note it pointed at (ids
// reassigned, data cleared). Point it back at the picker rather than leaving the user
// with an inert widget they can only fix by deleting and re-adding it.
views.setTextViewText(R.id.widget_title, context.getString(R.string.widget_note_unconfigured_title));
views.setTextViewText(R.id.widget_body, context.getString(R.string.widget_note_unconfigured_body));
views.setOnClickPendingIntent(R.id.open_note, getConfigurePendingIntent(context, appWidgetId));
appWidgetManager.updateAppWidget(appWidgetId, views);
return;
}
Gson gson = new Gson();
Note note = gson.fromJson(data, Note.class);
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.note_widget);
views.setTextViewText(R.id.widget_title, note.getTitle());
views.setTextViewText(R.id.widget_body, note.getHeadline());
// Once the user shrinks the widget down to a single row there is no room for the preview
// text, and a clipped half-line of it looks like a rendering glitch.
views.setViewVisibility(R.id.widget_body,
hasRoomForBody(appWidgetManager, appWidgetId) ? View.VISIBLE : View.GONE);
Intent intent = new Intent(context, MainActivity.class);
intent.putExtra(OpenNoteId, note.getId());
intent.setAction(Intent.ACTION_VIEW);
intent.putExtra(RCTNNativeModule.IntentType, "OpenNote");
intent.setData(Uri.parse("nn://note/" + note.getId()));
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
views.setOnClickPendingIntent(R.id.open_note, pendingIntent);
appWidgetManager.updateAppWidget(appWidgetId, views);
}
/**
* Reopens the configure screen for this widget. The launcher's own "reconfigure" gesture is
* hard to discover and not offered by every launcher, so an unconfigured widget needs its own
* way back in.
*/
private static PendingIntent getConfigurePendingIntent(Context context, int appWidgetId) {
Intent intent = new Intent(context, NotePreviewConfigureActivity.class);
intent.setAction(AppWidgetManager.ACTION_APPWIDGET_CONFIGURE);
intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
// PendingIntent equality ignores extras, so the widget id has to be the request code for
// each widget to get its own.
return PendingIntent.getActivity(context, appWidgetId, intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE,
WidgetUtils.getActivityOptionsBundle());
}
/**
* Height below which the note preview text is dropped, leaving just the title.
*/
private static final int MIN_HEIGHT_FOR_BODY_DP = 70;
private static boolean hasRoomForBody(AppWidgetManager appWidgetManager, int appWidgetId) {
Bundle options = appWidgetManager.getAppWidgetOptions(appWidgetId);
if (options == null) return true;
int minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT);
// Not reported yet (the widget has just been placed): assume there is room.
return minHeight <= 0 || minHeight >= MIN_HEIGHT_FOR_BODY_DP;
}
@Override
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
// This used to do nothing at all, so resizing the widget left it rendered for its old size.
updateAppWidget(context, appWidgetManager, appWidgetId);
}
private static Bundle getActivityOptionsBundle() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ActivityOptions activityOptions = ActivityOptions.makeBasic();
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
return activityOptions.toBundle();
} else
return null;
/**
* The note shown by each widget is stored in the "appPreview" preferences under its widget id.
* When the system restores our widgets it hands out fresh ids, so unless we move the stored
* notes over to the new ids the widgets are left permanently blank with no way to recover
* other than removing and re-adding them.
*
* AppWidgetProvider calls onUpdate() with the new ids right after this, which re-renders them.
*/
@Override
public void onRestored(Context context, int[] oldWidgetIds, int[] newWidgetIds) {
super.onRestored(context, oldWidgetIds, newWidgetIds);
if (oldWidgetIds == null || newWidgetIds == null) return;
int count = Math.min(oldWidgetIds.length, newWidgetIds.length);
SharedPreferences preferences = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE);
// Read everything up front: an old id can collide with the new id of another widget.
String[] notes = new String[count];
Set<String> newKeys = new HashSet<>();
for (int i = 0; i < count; i++) {
notes[i] = preferences.getString(String.valueOf(oldWidgetIds[i]), "");
newKeys.add(String.valueOf(newWidgetIds[i]));
}
SharedPreferences.Editor edit = preferences.edit();
for (int i = 0; i < count; i++) {
String oldKey = String.valueOf(oldWidgetIds[i]);
if (!newKeys.contains(oldKey)) {
edit.remove(oldKey);
}
}
for (int i = 0; i < count; i++) {
if (notes[i].isEmpty()) continue;
edit.putString(String.valueOf(newWidgetIds[i]), notes[i]);
}
edit.apply();
}
@Override

View File

@@ -1,12 +1,10 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.widget.RemoteViews;
@@ -24,20 +22,10 @@ public class NoteWidget extends AppWidgetProvider {
static void setClickIntent(Context context, RemoteViews views) {
Intent intent = new Intent(context, ShareActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
views.setOnClickPendingIntent(R.id.new_note, pendingIntent);
}
private static Bundle getActivityOptionsBundle() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ActivityOptions activityOptions = ActivityOptions.makeBasic();
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
return activityOptions.toBundle();
} else
return null;
}
@Override
public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, Bundle newOptions) {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);

View File

@@ -12,7 +12,6 @@ import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.graphics.drawable.Icon;
import android.os.Build;
import android.os.Bundle;
@@ -29,7 +28,6 @@ import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.google.gson.Gson;
import com.streetwriters.notesnook.datatypes.Note;
import java.util.ArrayList;
@@ -138,7 +136,7 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
if (Objects.equals(extras.getString(IntentType), "NewReminder")) {
map.putString(ReminderWidgetProvider.NewReminder, extras.getString(ReminderWidgetProvider.NewReminder));
} else if (Objects.equals(extras.getString(IntentType), "OpenReminder")) {
map.putString(ReminderViewsService.OpenReminderId, extras.getString(ReminderViewsService.OpenReminderId));
map.putString(ReminderWidgetProvider.OpenReminderId, extras.getString(ReminderWidgetProvider.OpenReminderId));
} else if (Objects.equals(extras.getString(IntentType), "OpenNote")) {
map.putString(NotePreviewWidget.OpenNoteId, extras.getString(NotePreviewWidget.OpenNoteId));
}
@@ -156,14 +154,8 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
@ReactMethod
public void getWidgetNotes(Promise promise) {
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
Map<String, ?> map = pref.getAll();
WritableArray arr = Arguments.createArray();
for(Map.Entry<String,?> entry : map.entrySet()){
if (entry.getKey().equals("remindersList")) continue;
String value = (String) entry.getValue();
Gson gson = new Gson();
Note note = gson.fromJson(value, Note.class);
for (Note note : WidgetUtils.getWidgetNotes(getReactApplicationContext()).values()) {
arr.pushString(note.getId());
}
promise.resolve(arr);
@@ -171,36 +163,46 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
@ReactMethod
public void hasWidgetNote(final String noteId, Promise promise) {
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
Map<String, ?> map = pref.getAll();
boolean found = false;
for(Map.Entry<String,?> entry : map.entrySet()){
String value = (String) entry.getValue();
if (value.contains(noteId)) {
for (Note note : WidgetUtils.getWidgetNotes(getReactApplicationContext()).values()) {
if (note.getId().equals(noteId)) {
found = true;
break;
}
}
promise.resolve(found);
}
@ReactMethod
public void updateWidgetNote(final String noteId, final String data) {
SharedPreferences pref = getReactApplicationContext().getSharedPreferences("appPreview", Context.MODE_PRIVATE);
Map<String, ?> map = pref.getAll();
SharedPreferences pref = getReactApplicationContext().getSharedPreferences(WidgetUtils.PREFERENCES, Context.MODE_PRIVATE);
SharedPreferences.Editor edit = pref.edit();
ArrayList<String> ids = new ArrayList<>();
for(Map.Entry<String,?> entry : map.entrySet()) {
String value = (String) entry.getValue();
if (value.contains(noteId)) {
edit.putString(entry.getKey(), data);
ids.add(entry.getKey());
}
List<Integer> ids = new ArrayList<>();
// Match on the note's id, not on the raw JSON containing it somewhere: a note whose body
// happens to mention another note's id is not the same note.
for (Map.Entry<Integer, Note> entry : WidgetUtils.getWidgetNotes(getReactApplicationContext()).entrySet()) {
if (!noteId.equals(entry.getValue().getId())) continue;
edit.putString(String.valueOf(entry.getKey()), data);
ids.add(entry.getKey());
}
edit.apply();
for (String id: ids) {
NotePreviewWidget.updateAppWidget(mContext, AppWidgetManager.getInstance(mContext), Integer.parseInt(id));
for (int id : ids) {
NotePreviewWidget.updateAppWidget(mContext, AppWidgetManager.getInstance(mContext), id);
}
}
/**
* Redraws every widget from scratch. Needed because the app can be stopped while its widgets
* stay on the home screen: clearing app data empties the store without the widgets ever being
* told, so they keep showing content that is gone until something forces a redraw.
*/
@ReactMethod
public void refreshWidgets() {
WidgetUtils.refreshAll(mContext);
}
@ReactMethod
public void updateReminderWidget() {
AppWidgetManager wm = AppWidgetManager.getInstance(mContext);
@@ -208,8 +210,8 @@ public class RCTNNativeModule extends ReactContextBaseJavaModule {
for (int id: ids) {
Log.d("Reminders", "Updating" + id);
RemoteViews views = new RemoteViews(mContext.getPackageName(), R.layout.widget_reminders);
// The rows are part of this update, so there is nothing left to invalidate afterwards.
ReminderWidgetProvider.updateAppWidget(mContext, wm, id, views);
wm.notifyAppWidgetViewDataChanged(id, R.id.widget_list_view);
}
}

View File

@@ -1,106 +0,0 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.widget.RemoteViewsService;
import android.content.Context;
import android.widget.RemoteViews;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.streetwriters.notesnook.datatypes.Reminder;
import java.util.ArrayList;
import java.util.List;
public class ReminderViewsService extends RemoteViewsService {
static String OpenReminderId = "com.streetwriters.notesnook.OpenReminderId";
@Override
public RemoteViewsFactory onGetViewFactory(Intent intent) {
return new ReminderRemoteViewsFactory(this.getApplicationContext(), intent);
}
}
class ReminderRemoteViewsFactory implements RemoteViewsService.RemoteViewsFactory {
private Context context;
private List<Reminder> reminders;
public ReminderRemoteViewsFactory(Context context, Intent intent) {
this.context = context;
}
@Override
public void onCreate() {
// Initialize reminders list
reminders = new ArrayList<Reminder>();
}
@Override
public void onDataSetChanged() {
reminders.clear();
SharedPreferences preferences = context.getSharedPreferences("appPreview", Context.MODE_PRIVATE);
Gson gson = new Gson();
reminders = gson.fromJson(preferences.getString("remindersList","[]"), new TypeToken<List<Reminder>>(){}.getType());
}
@Override
public void onDestroy() {
reminders.clear();
}
@Override
public int getCount() {
return reminders.size();
}
@Override
public RemoteViews getViewAt(int position) {
Reminder reminder = reminders.get(position);
boolean useMiniLayout = reminder.getDescription() == null || reminder.getDescription().isEmpty();
RemoteViews views = new RemoteViews(context.getPackageName(), useMiniLayout ? R.layout.widget_reminder_layout_small : R.layout.widget_reminder_layout);
views.setTextViewText(R.id.reminder_title, reminder.getTitle());
if (!useMiniLayout) {
views.setTextViewText(R.id.reminder_description, reminder.getDescription());
}
views.setTextViewText(R.id.reminder_time, reminder.getFormattedTime());
final Intent fillInIntent = new Intent();
final Bundle extras = new Bundle();
extras.putString(ReminderViewsService.OpenReminderId, reminder.getId());
fillInIntent.setData(Uri.parse("https://app.notesnook.com/open_reminder?id=" + reminder.getId()));
fillInIntent.putExtra(RCTNNativeModule.IntentType, "OpenReminder");
fillInIntent.putExtras(extras);
views.setOnClickFillInIntent(R.id.reminder_item_btn, fillInIntent);
return views;
}
@Override
public RemoteViews getLoadingView() {
return null;
}
@Override
public int getViewTypeCount() {
return 2;
}
@Override
public long getItemId(int position) {
return position;
}
@Override
public boolean hasStableIds() {
return true;
}
}

View File

@@ -1,18 +1,22 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.widget.RemoteViews;
import androidx.core.widget.RemoteViewsCompat;
import com.streetwriters.notesnook.datatypes.Reminder;
import java.util.List;
public class ReminderWidgetProvider extends AppWidgetProvider {
static String NewReminder = "com.streetwriters.notesnook.NewReminder";
static String OpenReminderId = "com.streetwriters.notesnook.OpenReminderId";
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
@@ -23,20 +27,10 @@ public class ReminderWidgetProvider extends AppWidgetProvider {
}
private static Bundle getActivityOptionsBundle() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ActivityOptions activityOptions = ActivityOptions.makeBasic();
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
return activityOptions.toBundle();
} else
return null;
}
static void updateAppWidget(Context context, AppWidgetManager appWidgetManager, int appWidgetId, RemoteViews views) {
Intent listview_intent_template = new Intent(context, MainActivity.class);
listview_intent_template.setAction(Intent.ACTION_VIEW);
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, listview_intent_template, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE, getActivityOptionsBundle());
PendingIntent pendingIntent = PendingIntent.getActivity(context, appWidgetId, listview_intent_template, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_MUTABLE, WidgetUtils.getActivityOptionsBundle());
views.setPendingIntentTemplate(R.id.widget_list_view, pendingIntent);
Intent new_reminder_intent = new Intent(context, MainActivity.class);
@@ -44,13 +38,31 @@ public class ReminderWidgetProvider extends AppWidgetProvider {
new_reminder_intent.setAction(Intent.ACTION_VIEW);
new_reminder_intent.putExtra(RCTNNativeModule.IntentType, "NewReminder");
new_reminder_intent.setData(Uri.parse("https://app.notesnook.com/new_reminder"));
PendingIntent pendingIntent2 = PendingIntent.getActivity(context, appWidgetId, new_reminder_intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE, getActivityOptionsBundle());
PendingIntent pendingIntent2 = PendingIntent.getActivity(context, appWidgetId, new_reminder_intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE, WidgetUtils.getActivityOptionsBundle());
views.setOnClickPendingIntent(R.id.add_button, pendingIntent2);
Intent list_remote_adapter_intent = new Intent(context, ReminderViewsService.class);
list_remote_adapter_intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId);
views.setRemoteAdapter(R.id.widget_list_view, list_remote_adapter_intent);
// The rows travel with the update itself, so there is no bound service to keep in sync and
// nothing to invalidate separately: every update redraws from the current data.
List<Reminder> reminders = WidgetUtils.getWidgetReminders(context);
RemoteViewsCompat.RemoteCollectionItems.Builder items =
new RemoteViewsCompat.RemoteCollectionItems.Builder();
for (Reminder reminder : reminders) {
items.addItem(getItemId(reminder), WidgetUtils.createReminderItem(context, reminder));
}
// Two, because a reminder without a description uses the compact row layout.
items.setViewTypeCount(2);
items.setHasStableIds(true);
RemoteViewsCompat.setRemoteAdapter(context, views, appWidgetId, R.id.widget_list_view, items.build());
views.setEmptyView(R.id.widget_list_view, R.id.empty_view);
appWidgetManager.updateAppWidget(appWidgetId, views);
}
}
/**
* Ties a row to its reminder rather than to its position, so rows keep their identity when the
* list shifts around them.
*/
private static long getItemId(Reminder reminder) {
return reminder.getId() == null ? 0 : reminder.getId().hashCode();
}
}

View File

@@ -0,0 +1,28 @@
package com.streetwriters.notesnook;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
/**
* Redraws the widgets when the clock or timezone changes.
*
* Reminder rows are described relative to the current time ("Upcoming"/"Last", "Today"/"Tomorrow"),
* and a reminder that has passed drops off the list entirely. All of that is decided when the
* widget is drawn, so moving the clock leaves the previous drawing in place: a reminder can still
* read "Upcoming" long after its time has gone by.
*
* The app normally redraws the list when a reminder notification is delivered, but that does not
* happen if the reminder never fires, which is exactly the case when the clock jumps past it.
*
* Only TIME_SET and TIMEZONE_CHANGED are handled here: DATE_CHANGED is not exempt from the
* Android 8 limits on manifest-registered implicit broadcasts, so a receiver for it would never
* run. Crossing midnight is instead picked up by the widget's own periodic update.
*/
public class WidgetTimeChangeReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
WidgetUtils.refreshAll(context);
}
}

View File

@@ -0,0 +1,299 @@
package com.streetwriters.notesnook;
import android.app.ActivityOptions;
import android.appwidget.AppWidgetManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.text.format.DateUtils;
import android.util.Log;
import android.widget.RemoteViews;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.streetwriters.notesnook.datatypes.Note;
import com.streetwriters.notesnook.datatypes.Reminder;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* Shared helpers for the home screen widgets.
*/
public class WidgetUtils {
static final String PREFERENCES = "appPreview";
static final String REMINDERS_KEY = "remindersList";
/**
* Every row is serialized into the widget update itself, which has to fit inside a binder
* transaction, so the list cannot grow without bound. Far more than fits on screen anyway.
*/
private static final int MAX_REMINDERS = 50;
/**
* Redraws every widget that currently exists, and drops stored notes for widgets that no
* longer do.
*
* Everything else keys off what we have stored, which is fine while the app is running but
* leaves widgets showing content that no longer exists once the store is emptied underneath
* them (clearing app data) or a widget is removed while the app is stopped (onDeleted never
* arrives). Starting from the widgets the system knows about, rather than from our own data,
* is what makes this self-correcting.
*
* NoteWidget is left alone deliberately: it is a static button with no stored state, and its
* layout depends on the size it was last given.
*/
static void refreshAll(Context context) {
AppWidgetManager manager = AppWidgetManager.getInstance(context);
int[] noteWidgetIds = manager.getAppWidgetIds(
new ComponentName(context, NotePreviewWidget.class));
removeOrphanedNotes(context, noteWidgetIds);
for (int appWidgetId : noteWidgetIds) {
NotePreviewWidget.updateAppWidget(context, manager, appWidgetId);
}
for (int appWidgetId : manager.getAppWidgetIds(
new ComponentName(context, ReminderWidgetProvider.class))) {
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_reminders);
ReminderWidgetProvider.updateAppWidget(context, manager, appWidgetId, views);
}
}
/**
* Drops stored notes whose widget is gone, so the preferences file cannot grow forever.
*/
private static void removeOrphanedNotes(Context context, int[] liveWidgetIds) {
Set<String> live = new HashSet<>();
for (int appWidgetId : liveWidgetIds) live.add(String.valueOf(appWidgetId));
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
SharedPreferences.Editor edit = preferences.edit();
boolean changed = false;
for (String key : preferences.getAll().keySet()) {
// Leave anything that is not a widget id alone, the reminders list included.
if (parseWidgetId(key) == null || live.contains(key)) continue;
edit.remove(key);
changed = true;
}
if (changed) edit.apply();
}
/**
* The note each note widget is showing, keyed by widget id.
*
* The preferences file mixes two things: one note per widget id, and the reminders list under
* its own key. Only numeric keys are widget notes, so anything else is skipped rather than
* being treated as a note.
*/
static Map<Integer, Note> getWidgetNotes(Context context) {
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
Map<Integer, Note> notes = new LinkedHashMap<>();
for (Map.Entry<String, ?> entry : preferences.getAll().entrySet()) {
Integer widgetId = parseWidgetId(entry.getKey());
if (widgetId == null) continue;
if (!(entry.getValue() instanceof String)) continue;
Note note = parseNote((String) entry.getValue());
if (note == null || note.getId() == null) continue;
notes.put(widgetId, note);
}
return notes;
}
/**
* The widget id a preferences key refers to, or null if the key is not a widget id at all.
*/
private static Integer parseWidgetId(String key) {
try {
return Integer.valueOf(key);
} catch (NumberFormatException e) {
return null;
}
}
static Note parseNote(String data) {
if (data == null || data.isEmpty()) return null;
try {
return new Gson().fromJson(data, Note.class);
} catch (Exception e) {
Log.e("NotePreviewWidget", "Could not read a stored note", e);
return null;
}
}
/**
* The reminders the app last wrote out, minus any that have now dropped out of view. Reading
* and filtering happens here so the provider can push the rows straight into the widget.
*/
static List<Reminder> getWidgetReminders(Context context) {
SharedPreferences preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
List<Reminder> stored = null;
try {
stored = new Gson().fromJson(preferences.getString(REMINDERS_KEY, "[]"),
new TypeToken<List<Reminder>>() {}.getType());
} catch (Exception e) {
Log.e("Reminders", "Could not read the stored reminders list", e);
}
List<Reminder> active = new ArrayList<>();
if (stored == null) return active;
for (Reminder reminder : stored) {
if (!isVisibleInWidget(reminder)) continue;
if (active.size() >= MAX_REMINDERS) {
Log.w("Reminders", "Widget list truncated to " + MAX_REMINDERS + " reminders");
break;
}
active.add(reminder);
}
return active;
}
/**
* Builds a single row of the reminders list.
*/
static RemoteViews createReminderItem(Context context, Reminder reminder) {
boolean useMiniLayout = reminder.getDescription() == null || reminder.getDescription().isEmpty();
RemoteViews views = new RemoteViews(context.getPackageName(),
useMiniLayout ? R.layout.widget_reminder_layout_small : R.layout.widget_reminder_layout);
views.setTextViewText(R.id.reminder_title, reminder.getTitle());
if (!useMiniLayout) {
views.setTextViewText(R.id.reminder_description, reminder.getDescription());
}
views.setTextViewText(R.id.reminder_time, formatReminderTime(context, reminder));
Intent fillInIntent = new Intent();
fillInIntent.setData(Uri.parse("https://app.notesnook.com/open_reminder?id=" + reminder.getId()));
fillInIntent.putExtra(RCTNNativeModule.IntentType, "OpenReminder");
fillInIntent.putExtra(ReminderWidgetProvider.OpenReminderId, reminder.getId());
views.setOnClickFillInIntent(R.id.reminder_item_btn, fillInIntent);
return views;
}
/**
* Options attached to the PendingIntents our widgets hand to the launcher, opting the creator
* (us) in to background activity starts so a tap on the widget can bring up an activity.
*
* MODE_BACKGROUND_ACTIVITY_START_ALLOWED is deprecated since API 36 and Android 17 extends the
* background activity launch restrictions to IntentSender, so on API 36+ we use the narrower
* MODE_BACKGROUND_ACTIVITY_START_ALLOW_IF_VISIBLE instead. That is enough for widgets: the
* sender is the launcher, which is visible whenever the user taps the widget.
*/
static Bundle getActivityOptionsBundle() {
ActivityOptions activityOptions;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
activityOptions = ActivityOptions.makeBasic();
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOW_IF_VISIBLE);
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
activityOptions = ActivityOptions.makeBasic();
activityOptions.setPendingIntentCreatorBackgroundActivityStartMode(
ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED);
} else {
return null;
}
return activityOptions.toBundle();
}
/**
* How long a reminder keeps its place in the list after going off, so the user can see that it
* happened rather than watching it vanish. Must match RECENTLY_PASSED_WINDOW in
* services/notifications.ts, which decides what gets written out in the first place.
*/
private static final long RECENTLY_PASSED_WINDOW_MS = TimeUnit.HOURS.toMillis(3);
/**
* Whether a reminder should still be drawn.
*
* We re-check here rather than trusting the stored list because that list is only rewritten
* while the app runs. This is what actually retires a reminder once its grace period is up:
* every redraw re-evaluates it against the current time.
*/
static boolean isVisibleInWidget(Reminder reminder) {
if (reminder == null) return false;
if (reminder.isDisabled()) return false;
long now = System.currentTimeMillis();
if (reminder.getSnoozeUntil() > now) return true;
if (!"once".equals(reminder.getMode())) return true;
long triggerDate = reminder.getTriggerDate() > 0 ? reminder.getTriggerDate() : reminder.getDate();
return triggerDate > now - RECENTLY_PASSED_WINDOW_MS;
}
/**
* Builds the label shown under a reminder. The app sends us the absolute trigger time plus the
* parts that never change ("5:00 PM", "12-05-2026, 5:00 PM"); everything that depends on the
* current time is decided here so it stays right as the widget redraws.
*
* Falls back to the pre-formatted string for lists written by an older version of the app.
*/
static String formatReminderTime(Context context, Reminder reminder) {
long triggerDate = reminder.getTriggerDate();
String timeOfDay = reminder.getFormattedTimeOfDay();
if (triggerDate <= 0 || timeOfDay == null || timeOfDay.isEmpty()) {
return reminder.getFormattedTime();
}
if ("permanent".equals(reminder.getMode())) {
return context.getString(R.string.reminder_ongoing);
}
long now = System.currentTimeMillis();
if (reminder.getSnoozeUntil() > now) {
return context.getString(R.string.reminder_snoozed_until, timeOfDay);
}
String text;
long dayOffset = daysFromToday(triggerDate, now);
if (dayOffset == 0) {
text = context.getString(R.string.reminder_today, timeOfDay);
} else if (dayOffset == 1) {
text = context.getString(R.string.reminder_tomorrow, timeOfDay);
} else if (dayOffset == -1) {
text = context.getString(R.string.reminder_yesterday, timeOfDay);
} else {
text = reminder.getFormattedDateTime();
if (text == null || text.isEmpty()) return reminder.getFormattedTime();
}
return context.getString(
triggerDate <= now ? R.string.reminder_last : R.string.reminder_upcoming, text);
}
/**
* Calendar days between two instants. Compares midnights rather than subtracting the raw
* difference so that "tomorrow" is still tomorrow across a DST change or just before midnight.
*/
private static long daysFromToday(long time, long now) {
long target = startOfDay(time);
long today = startOfDay(now);
return Math.round((target - today) / (double) DateUtils.DAY_IN_MILLIS);
}
private static long startOfDay(long time) {
Calendar calendar = Calendar.getInstance();
calendar.setTimeInMillis(time);
calendar.set(Calendar.HOUR_OF_DAY, 0);
calendar.set(Calendar.MINUTE, 0);
calendar.set(Calendar.SECOND, 0);
calendar.set(Calendar.MILLISECOND, 0);
return calendar.getTimeInMillis();
}
}

View File

@@ -2,13 +2,14 @@ package com.streetwriters.notesnook.datatypes;
import androidx.annotation.Keep;
import java.util.concurrent.TimeUnit;
@Keep
public class Reminder extends BaseItem {
private String title;
private String description;
private String formattedTime;
private String formattedTimeOfDay; // e.g. "5:00 PM"
private String formattedDateTime; // e.g. "12-05-2026, 5:00 PM"
private long triggerDate; // absolute time this reminder next fires
private String priority; // "silent", "vibrate", "urgent"
private long date;
private String mode; // "repeat", "once", "permanent"
@@ -107,32 +108,27 @@ public class Reminder extends BaseItem {
this.formattedTime = formattedTime;
}
public String formatTime(long timeInMillis) {
long currentTime = System.currentTimeMillis();
long diff = timeInMillis - currentTime;
public String getFormattedTimeOfDay() {
return formattedTimeOfDay;
}
if (diff < TimeUnit.MINUTES.toMillis(1)) {
return "in " + (diff / 1000) + " seconds";
} else if (diff < TimeUnit.HOURS.toMillis(1)) {
long minutes = TimeUnit.MILLISECONDS.toMinutes(diff);
return "in " + minutes + " minute" + (minutes > 1 ? "s" : "");
} else if (diff < TimeUnit.DAYS.toMillis(1)) {
long hours = TimeUnit.MILLISECONDS.toHours(diff);
return "in " + hours + " hour" + (hours > 1 ? "s" : "");
} else if (diff < TimeUnit.DAYS.toMillis(2)) {
return "tomorrow";
} else if (diff < TimeUnit.DAYS.toMillis(7)) {
long days = TimeUnit.MILLISECONDS.toDays(diff);
return "in " + days + " day" + (days > 1 ? "s" : "");
} else if (diff < TimeUnit.DAYS.toMillis(30)) {
long weeks = TimeUnit.MILLISECONDS.toDays(diff) / 7;
return "in " + weeks + " week" + (weeks > 1 ? "s" : "");
} else if (diff < TimeUnit.DAYS.toMillis(365)) {
long months = TimeUnit.MILLISECONDS.toDays(diff) / 30;
return "in " + months + " month" + (months > 1 ? "s" : "");
} else {
long years = TimeUnit.MILLISECONDS.toDays(diff) / 365;
return "in " + years + " year" + (years > 1 ? "s" : "");
}
public void setFormattedTimeOfDay(String formattedTimeOfDay) {
this.formattedTimeOfDay = formattedTimeOfDay;
}
public String getFormattedDateTime() {
return formattedDateTime;
}
public void setFormattedDateTime(String formattedDateTime) {
this.formattedDateTime = formattedDateTime;
}
public long getTriggerDate() {
return triggerDate;
}
public void setTriggerDate(long triggerDate) {
this.triggerDate = triggerDate;
}
}

View File

@@ -2,6 +2,6 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/background"/>
<stroke android:width="0dp" android:color="#B1BCBE" />
<corners android:radius="10dp"/>
<corners android:radius="@dimen/widget_background_radius"/>
<padding android:left="0dp" android:top="0dp" android:right="0dp" android:bottom="0dp" />
</shape>

View File

@@ -2,8 +2,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:theme="@style/ThemeOverlay.Notesnook.AppWidgetContainer">
android:background="@android:color/transparent">
<LinearLayout
android:layout_width="match_parent"

View File

@@ -2,8 +2,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent"
android:theme="@style/ThemeOverlay.Notesnook.AppWidgetContainer">
android:background="@android:color/transparent">
<LinearLayout
android:layout_width="match_parent"
@@ -25,7 +24,7 @@
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold"
android:text="Widget unconfigured" />
android:text="@string/widget_note_unconfigured_title" />
<TextView
android:id="@+id/widget_body"
android:layout_width="wrap_content"
@@ -34,7 +33,7 @@
android:layout_marginLeft="8dp"
android:textColor="@color/text"
android:textSize="14sp"
android:text="Configure this widget to show a note here." />
android:text="@string/widget_note_unconfigured_body" />
</LinearLayout>
</RelativeLayout>

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?><!--
Shown in the widget picker only. Mirrors note_widget.xml, but with sample content, since the
real layout has nothing to show until the widget has been configured.
-->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/transparent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_centerVertical="true"
android:background="@drawable/layout_bg"
android:elevation="5dp"
android:orientation="vertical"
android:paddingHorizontal="10dp"
android:paddingVertical="10dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginLeft="8dp"
android:text="@string/widget_preview_note_title"
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginLeft="8dp"
android:text="@string/widget_preview_note_body"
android:textColor="@color/text"
android:textSize="14sp" />
</LinearLayout>
</RelativeLayout>

View File

@@ -1,8 +0,0 @@
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:text="No upcoming reminders"
android:textSize="16sp"
android:textColor="@android:color/darker_gray"
android:gravity="center" />

View File

@@ -12,22 +12,23 @@
android:orientation="horizontal"
android:paddingHorizontal="12dp"
android:layout_gravity="center"
android:paddingTop="12dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_marginTop="2dp"
android:textSize="16sp"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="@color/text"
android:text="Upcoming Reminders"/>
android:text="Reminders"/>
<ImageButton
android:layout_width="35dp"
android:layout_width="25dp"
android:id="@+id/add_button"
android:layout_height="35dp"
android:layout_height="25dp"
android:layout_alignParentRight="true"
android:background="@drawable/ic_newnote" />
</RelativeLayout>
@@ -57,7 +58,8 @@
android:layout_height="match_parent"
android:textAlignment="center"
android:gravity="center"
android:text="Tap on + to add reminder"/>
android:textColor="@color/text"
android:text="@string/widget_reminders_empty"/>
</LinearLayout>

View File

@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="utf-8"?><!--
Shown in the widget picker only. Mirrors widget_reminders.xml, but with the list replaced by
sample rows, since an adapter-backed list renders empty in the picker.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/layout_bg"
android:orientation="vertical">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:paddingHorizontal="12dp"
android:paddingTop="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentLeft="true"
android:layout_marginTop="2dp"
android:text="Reminders"
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold" />
<ImageButton
android:layout_width="35dp"
android:layout_height="35dp"
android:layout_alignParentRight="true"
android:background="@drawable/ic_newnote"
android:contentDescription="@string/add_widget" />
</RelativeLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="2dp"
android:layout_marginBottom="8dp"
android:background="@color/border" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="12dp"
android:paddingBottom="12dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_preview_reminder_title"
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:text="@string/widget_preview_reminder_time"
android:textColor="@color/text"
android:textSize="12sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_preview_reminder_title_alt"
android:textColor="@color/text"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/widget_preview_reminder_time_alt"
android:textColor="@color/text"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,9 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="light_blue_50">#FFE1F5FE</color>
<color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
<color name="bootsplash_background">#1f1f1f</color>
<color name="background">#1D1D1D</color>
<color name="border">#2E2E2E</color>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Android 12 onwards the launcher tells us what radius widgets should use, so ours line up
with every other widget on the home screen instead of being a fixed 10dp. -->
<dimen name="widget_background_radius">@android:dimen/system_app_widget_background_radius</dimen>
</resources>

View File

@@ -1,6 +0,0 @@
<resources>
<declare-styleable name="AppWidgetAttrs">
<attr name="appWidgetBackgroundColor" format="color" />
<attr name="appWidgetTextColor" format="color" />
</declare-styleable>
</resources>

View File

@@ -1,9 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="light_blue_50">#FFE1F5FE</color>
<color name="light_blue_200">#FF81D4FA</color>
<color name="light_blue_600">#FF039BE5</color>
<color name="light_blue_900">#FF01579B</color>
<color name="bootsplash_background">#FFFFFF</color>
<color name="background">#DCEDEDED</color>
<color name="border">#BFBFBF</color>

View File

@@ -7,4 +7,8 @@ http://developer.android.com/guide/topics/appwidgets/index.html#CreatingLayout
-->
<dimen name="widget_margin">0dp</dimen>
<!-- Overridden in values-v31 with the platform's own widget radius, so our widgets match the
rest of the home screen. This is the fallback for older versions. -->
<dimen name="widget_background_radius">10dp</dimen>
</resources>

View File

@@ -4,9 +4,28 @@
<string name="appwidget_text">EXAMPLE</string>
<string name="add_widget">Add widget</string>
<string name="take_a_quick_note">Take a quick note.</string>
<string name="reminders">Quick overview of upcoming reminders</string>
<string name="reminders">Quick overview of reminders</string>
<string name="reminders_title">Reminders</string>
<string name="note">Note</string>
<string name="note_description">Add a note to home screen</string>
<string name="quick_note">Quick note</string>
<string name="widget_reminders_empty">Tap + to add a reminder</string>
<!-- Sample content, only ever shown in the widget picker's preview. -->
<string name="widget_preview_note_title">Meeting notes</string>
<string name="widget_preview_note_body">Discuss the roadmap and agree on timelines.</string>
<string name="widget_preview_reminder_title">Take a walk</string>
<string name="widget_preview_reminder_time">Upcoming: Today, 5:00 PM</string>
<string name="widget_preview_reminder_title_alt">Call the dentist</string>
<string name="widget_preview_reminder_time_alt">Upcoming: Tomorrow, 9:00 AM</string>
<string name="widget_note_unconfigured_title">Tap to choose a note</string>
<string name="widget_note_unconfigured_body">Pick the note you want shown here.</string>
<string name="reminder_ongoing">Ongoing</string>
<string name="reminder_snoozed_until">Snoozed until %1$s</string>
<string name="reminder_today">Today, %1$s</string>
<string name="reminder_tomorrow">Tomorrow, %1$s</string>
<string name="reminder_yesterday">Yesterday, %1$s</string>
<string name="reminder_upcoming">Upcoming: %1$s</string>
<string name="reminder_last">Last: %1$s</string>
</resources>

View File

@@ -1,7 +0,0 @@
<resources>
<style name="ThemeOverlay.Notesnook.AppWidgetContainer" parent="">
<item name="appWidgetBackgroundColor">@color/light_blue_600</item>
<item name="appWidgetTextColor">@color/light_blue_50</item>
</style>
</resources>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?><!--
Keep this in sync with res/xml/note_widget_info.xml. It exists only to add not_keyguard: from
Android 16 QPR1 widgets are lock screen eligible by default, and this one renders the note's
title and preview text, which should not be readable on a locked device.
-->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialKeyguardLayout="@layout/note_widget"
android:initialLayout="@layout/note_widget"
android:configure="com.streetwriters.notesnook.NotePreviewConfigureActivity"
android:widgetFeatures="reconfigurable"
android:minResizeWidth="100dp"
android:minResizeHeight="50dp"
android:minWidth="400dp"
android:description="@string/note_description"
android:minHeight="50dp"
android:targetCellWidth="5"
android:targetCellHeight="1"
android:previewImage="@drawable/note_widget_preview"
android:previewLayout="@layout/note_widget_preview"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen|not_keyguard"/>

View File

@@ -0,0 +1,15 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_reminders"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:description="@string/reminders"
android:targetCellWidth="5"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:previewImage="@drawable/reminder_preview"
android:previewLayout="@layout/widget_reminders_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen|not_keyguard"
/>

View File

@@ -10,6 +10,7 @@
android:targetCellWidth="5"
android:targetCellHeight="1"
android:previewImage="@drawable/widget_preview"
android:previewLayout="@layout/new_note_widget"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen"/>

View File

@@ -12,6 +12,7 @@
android:targetCellWidth="5"
android:targetCellHeight="1"
android:previewImage="@drawable/note_widget_preview"
android:previewLayout="@layout/note_widget_preview"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="86400000"
android:widgetCategory="home_screen"/>

View File

@@ -1,14 +1,15 @@
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_reminders"
android:minWidth="400dp"
android:minHeight="100dp"
android:minResizeWidth="400dp"
android:minResizeHeight="50dp"
android:minWidth="250dp"
android:minHeight="110dp"
android:minResizeWidth="180dp"
android:minResizeHeight="110dp"
android:description="@string/reminders"
android:targetCellWidth="5"
android:targetCellHeight="2"
android:resizeMode="horizontal|vertical"
android:previewImage="@drawable/reminder_preview"
android:updatePeriodMillis="1024"
android:previewLayout="@layout/widget_reminders_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen"
/>

View File

@@ -13,6 +13,7 @@ buildscript {
androidXCore = "1.7.0"
androidXBrowser = "1.0.0"
ndkVersion = "27.1.12297006"
playBillingSdkVersion = "8.0.0"
}
repositories {
@@ -41,3 +42,12 @@ allprojects {
maven { url 'https://www.jitpack.io' }
}
}
// Pin the ndkVersion for all subprojects
subprojects { subproject ->
["com.android.application", "com.android.library"].each { pluginId ->
subproject.plugins.withId(pluginId) {
subproject.android.ndkVersion = rootProject.ext.ndkVersion
}
}
}

View File

@@ -23,7 +23,7 @@ import {
THEME_COMPATIBILITY_VERSION,
useThemeEngineStore
} from "@notesnook/theme";
import React, { PropsWithChildren, useEffect } from "react";
import React, { PropsWithChildren, useEffect, useState } from "react";
import { Appearance, I18nManager, Linking, StatusBar } from "react-native";
import "react-native-gesture-handler";
import { GestureHandlerRootView } from "react-native-gesture-handler";
@@ -44,26 +44,37 @@ import { useUserStore } from "./stores/use-user-store";
import RNBootSplash from "react-native-bootsplash";
import AppLocked from "./components/app-lock";
import { useSettingStore } from "./stores/use-setting-store";
import {
initShortcutListener,
launchNewNoteTab,
registerAppShortcuts
} from "./hooks/use-shortcut-manager";
import Shortcuts from "react-native-actions-shortcuts";
I18nManager.allowRTL(false);
I18nManager.forceRTL(false);
I18nManager.swapLeftAndRightInRTL(false);
const { appLockEnabled, appLockMode } = SettingsService.get();
if (appLockEnabled || appLockMode !== "none") {
useUserStore.getState().lockApp(true);
}
RNBootSplash.hide({
fade: true
});
Linking.getInitialURL().then((url) => {
useSettingStore.setState({
initialUrl: url
});
});
const App = (props: { configureMode: "note-preview" }) => {
useAppEvents();
//@ts-ignore
globalThis["IS_MAIN_APP_RUNNING"] = true;
const introCompleted = useSettingStore(
(state) => state.settings.introCompleted
);
useEffect(() => {
if (introCompleted) {
registerAppShortcuts();
}
}, [introCompleted]);
useEffect(() => {
RNBootSplash.hide({ fade: true });
SettingsService.onFirstLaunch();
changeSystemBarColors();
SettingsService.setPrivacyScreen(
@@ -176,4 +187,41 @@ export const withTheme = (
};
};
export default withTheme(withErrorBoundry(App, "App"));
export const withStartupBoundry = (
Element: (props: PropsWithChildren) => JSX.Element
) => {
return function AppWithStartupBoundary(props: PropsWithChildren) {
const [ready, setReady] = useState(false);
useEffect(() => {
async function init() {
try {
const [url, shortcut] = await Promise.all([
Linking.getInitialURL(),
Shortcuts.getInitialShortcut()
]);
console.log(url, shortcut);
if (shortcut?.type === "notesnook.action.newnote") {
launchNewNoteTab();
}
useSettingStore.setState({
initialUrl: url,
pendingShortcut: shortcut ?? null
});
initShortcutListener();
} finally {
setReady(true);
}
}
init();
}, []);
if (!ready) return null;
return <Element {...props} />;
};
};
export default withStartupBoundry(withTheme(withErrorBoundry(App, "App")));

View File

@@ -24,6 +24,8 @@ import { Button } from "../ui/button";
import { IconButton } from "../ui/icon-button";
import { hideAuth } from "./common";
import { AuthParams } from "../../stores/use-navigation-store";
import { strings } from "@notesnook/intl";
import { presentDialog } from "../dialog/functions";
export const AuthHeader = (props: { welcome?: boolean }) => {
const { colors } = useThemeColors();
const route = useRoute();
@@ -56,18 +58,17 @@ export const AuthHeader = (props: { welcome?: boolean }) => {
{!props.welcome ? null : (
<Button
title="Skip"
title={strings.skipAndGoToApp()}
onPress={() => {
hideAuth();
presentDialog({
title: strings.offlineMode(),
paragraph: strings.offlineModeDesc(),
positiveText: strings.understand(),
positivePress: hideAuth as any
});
}}
iconSize={16}
type="plain"
iconPosition="right"
icon="chevron-right"
height={25}
iconStyle={{
marginTop: 2
}}
style={{
paddingHorizontal: 6
}}

View File

@@ -36,6 +36,7 @@ import { getElevationStyle } from "../../utils/elevation";
import { AppFontSize, normalize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
import { hexToRGBA, RGB_Linear_Shade } from "../../utils/colors";
import useGlobalSafeAreaInsets from "../../hooks/use-global-safe-area-insets";
interface FloatingButtonProps {
onPress: () => void;
@@ -47,6 +48,7 @@ interface FloatingButtonProps {
position?: "left" | "right";
size?: "small" | "large";
style?: ViewStyle;
hideOnKeyboard?: boolean;
}
const FloatingButton = ({
@@ -57,26 +59,28 @@ const FloatingButton = ({
testID,
position = "right",
size = "large",
style
style,
hideOnKeyboard = true
}: FloatingButtonProps) => {
const { colors } = useThemeColors();
const deviceMode = useSettingStore((state) => state.deviceMode);
const selectionMode = useSelectionStore((state) => state.selectionMode);
const translate = useSharedValue(0);
const keyboardHeight = useSharedValue(0);
const route = useRoute();
const insets = useGlobalSafeAreaInsets();
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateX: translate.value
},
{
translateY: translate.value
}
]
};
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{
translateX: hideOnKeyboard ? translate.value : 0
},
{
translateY: hideOnKeyboard ? translate.value : 0
}
],
bottom: hideOnKeyboard ? 15 : keyboardHeight.value + 15
}));
const animate = useCallback(
(toValue: number) => {
@@ -97,12 +101,22 @@ const FloatingButton = ({
editorState().keyboardState = false;
if (deviceMode !== "mobile") return;
animate(0);
keyboardHeight.value = withTiming(0);
};
const onKeyboardShow = () => {
const onKeyboardShow = (e: any) => {
editorState().keyboardState = true;
if (deviceMode !== "mobile") return;
animate(150);
if (hideOnKeyboard) {
animate(150);
} else {
keyboardHeight.value = withTiming(
e.endCoordinates.height - insets.bottom,
{
duration: 250
}
);
}
};
const sub = [
@@ -110,9 +124,9 @@ const FloatingButton = ({
Keyboard.addListener("keyboardDidHide", onKeyboardHide)
];
return () => {
sub.forEach((sub) => sub?.remove?.());
sub.forEach((sub) => sub.remove());
};
}, [deviceMode, animate]);
}, [deviceMode, animate, hideOnKeyboard, keyboardHeight, insets.bottom]);
return deviceMode !== "mobile" && !alwaysVisible ? null : (
<Animated.View
@@ -161,10 +175,10 @@ const FloatingButton = ({
icon
? icon
: route.name === "Notebooks"
? "notebook-plus"
: route.name === "Trash"
? "delete"
: "plus"
? "notebook-plus"
: route.name === "Trash"
? "delete"
: "plus"
}
color={color || colors.primary.accent}
size={size === "small" ? AppFontSize.xl : AppFontSize.xxxl}

View File

@@ -143,9 +143,6 @@ export const Dialog = ({ context = "global" }: { context?: string }) => {
}, [hide, show]);
const onNegativePress = async () => {
if (dialogInfo?.onClose) {
await dialogInfo.onClose();
}
hide();
};

View File

@@ -51,9 +51,10 @@ interface TabProps extends ViewProps {
onScroll: (offset: number) => void;
enabled: boolean;
onDrawerStateChange: (state: boolean) => void;
initialPage?: FluidTabPage;
}
type FluidTabPage = "home" | "editor";
export type FluidTabPage = "home" | "editor";
export interface TabsRef {
goToPage: (page: FluidTabPage, animated?: boolean) => void;
@@ -77,15 +78,21 @@ export const FluidPanels = forwardRef<TabsRef, TabProps>(function FluidTabs(
onChangeTab,
onScroll,
enabled,
onDrawerStateChange
onDrawerStateChange,
initialPage
}: TabProps,
ref
) {
const deviceMode = useSettingStore((state) => state.deviceMode);
const fullscreen = useSettingStore((state) => state.fullscreen);
const translateX = useSharedValue(widths ? widths.sidebar : 0);
const editorStartPosition = widths.sidebar + widths.list;
const translateX = useSharedValue(
initialPage === "editor" ? editorStartPosition : widths ? widths.sidebar : 0
);
const startX = useSharedValue(0);
const currentTab = useSharedValue(1);
const currentTab = useSharedValue(
initialPage === "editor" && deviceMode !== "tablet" ? 2 : 1
);
const previousTab = useSharedValue(1);
const isDrawerOpen = useSharedValue(false);
const gestureStartValue = useSharedValue({

View File

@@ -58,9 +58,9 @@ export const openNote = async (
}
if (isTrash) {
if (!note.contentId) return;
const content = await db.content.get(note.contentId as string);
const content = note.contentId
? await db.content.get(note.contentId)
: undefined;
presentSheet({
component: <NotePreview note={item} content={content} />
});

View File

@@ -72,6 +72,7 @@ const MergeConflicts = () => {
const insets = useGlobalSafeAreaInsets();
const content = useRef<UnencryptedContentItem>(null);
const { height } = useSettingStore((state) => state.dimensions);
const keepScreenOn = useSettingStore((state) => state.settings.keepScreenOn);
const applyChanges = async () => {
const contentToSave = selectedContent;
@@ -155,9 +156,9 @@ const MergeConflicts = () => {
...item.content,
conflicted: currentContent?.conflicted
? await db.vault.decryptContent(
currentContent?.conflicted as EncryptedContentItem,
password
)
currentContent?.conflicted as EncryptedContentItem,
password
)
: undefined
} as UnencryptedContentItem;
@@ -182,9 +183,9 @@ const MergeConflicts = () => {
...item.content,
conflicted: currentContent?.conflicted
? await db.vault.decryptContent(
currentContent?.conflicted as EncryptedContentItem,
password
)
currentContent?.conflicted as EncryptedContentItem,
password
)
: undefined
} as UnencryptedContentItem;
@@ -385,7 +386,7 @@ const MergeConflicts = () => {
paddingTop: insets.top
}}
>
<KeepAwake />
{keepScreenOn && <KeepAwake />}
{dialogVisible && (
<BaseDialog visible={true}>
<DialogContainer>

View File

@@ -29,7 +29,7 @@ import { ActionSheetRef, ScrollView } from "react-native-actions-sheet";
import Icon from "react-native-vector-icons/MaterialCommunityIcons";
import { db } from "../../common/database";
import { useDBItem } from "../../hooks/use-db-item";
import { presentSheet } from "../../services/event-manager";
import { presentSheet, ToastManager } from "../../services/event-manager";
import { openLinkInBrowser } from "../../utils/functions";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
@@ -39,15 +39,19 @@ import { Pressable } from "../ui/pressable";
import Seperator from "../ui/seperator";
import Paragraph from "../ui/typography/paragraph";
import NotePreview from "./preview";
import { presentDialog } from "../dialog/functions";
import { Dialog } from "../dialog";
const HistoryItem = ({
index,
items,
note
note,
refresh
}: {
index: number;
items?: VirtualizedGrouping<HistorySession>;
note: Note;
refresh: () => void;
}) => {
const [item] = useDBItem(index, "noteHistory", items);
const { colors } = useThemeColors();
@@ -75,12 +79,13 @@ const HistoryItem = ({
}}
content={content}
note={note}
update={refresh}
/>
),
context: "note_history"
});
},
[note]
[note, refresh]
);
return (
@@ -112,7 +117,8 @@ const HistoryItem = ({
};
export default function NoteHistory({
note
note,
fwdRef
}: {
note: Note;
fwdRef: RefObject<ActionSheetRef>;
@@ -121,7 +127,7 @@ export default function NoteHistory({
const [_loading, setLoading] = useState(true);
const { colors } = useThemeColors();
useEffect(() => {
const refresh = useCallback(() => {
db.noteHistory
.get(note.id)
.sorted({
@@ -137,17 +143,56 @@ export default function NoteHistory({
});
}, [note.id]);
useEffect(() => {
refresh();
}, [refresh]);
const renderItem = useCallback(
({ index }: { index: number }) => (
<HistoryItem index={index} items={history} note={note} />
<HistoryItem
index={index}
items={history}
note={note}
refresh={refresh}
/>
),
[history, note]
[history, note, refresh]
);
return (
<View>
<SheetProvider context="note_history" />
<DialogHeader title={strings.noteHistory()} padding={12} />
<Dialog context="local" />
<DialogHeader
title={strings.noteHistory()}
padding={12}
button={
history?.placeholders.length
? {
title: strings.clearHistory(),
type: "secondary",
onPress: () => {
presentDialog({
title: strings.clearHistory(),
paragraph: strings.clearHistoryConfirmation(),
positiveText: strings.clear(),
negativeText: strings.cancel(),
positiveType: "errorShade",
context: "local",
positivePress: async () => {
await db.noteHistory.clearSessions(note.id);
fwdRef.current?.hide();
ToastManager.show({
heading: strings.historyCleared(),
type: "success"
});
}
});
}
}
: undefined
}
/>
<Seperator />
@@ -204,7 +249,7 @@ export default function NoteHistory({
<Text
onPress={() => {
openLinkInBrowser(
"https://help.notesnook.com/note-version-history"
"https://notesnook.com/help/note-version-history"
);
}}
style={{

View File

@@ -53,7 +53,8 @@ import {
export default function NotePreview({
session,
content,
note
note,
update
}: {
session?: HistorySession & { session: string };
content:
@@ -64,6 +65,7 @@ export default function NotePreview({
>
| undefined;
note: TrashOrItem<Note>;
update?: () => void;
}) {
const { colors } = useThemeColors();
const [locked, setLocked] = useState(false);
@@ -107,24 +109,35 @@ export default function NotePreview({
}, [note]);
const deleteNote = async () => {
const isSession = !!session;
presentDialog({
title: strings.deleteNote(),
paragraph: strings.deleteNoteConfirmation(),
title: isSession ? strings.deleteVersion() : strings.deleteNote(),
paragraph: isSession
? strings.deleteVersionConfirmation()
: strings.deleteNoteConfirmation(),
positiveText: strings.delete(),
negativeText: strings.cancel(),
context: "local",
context: "preview",
positivePress: async () => {
if (note) {
if (isSession) {
await db.noteHistory.remove(session.id);
update?.();
eSendEvent(eCloseSheet, "note_history");
Navigation.queueRoutesForUpdate();
} else if (note.type === "trash") {
await db.trash.delete(note.id);
useTrashStore.getState().refresh();
useSelectionStore.getState().setSelectionMode();
ToastManager.show({
heading: strings.noteDeleted(),
type: "success",
context: "local"
});
eSendEvent(eCloseSheet);
}
ToastManager.show({
heading: isSession ? strings.versionDeleted() : strings.noteDeleted(),
type: "success",
context: isSession ? "local" : "global"
});
return true;
},
positiveType: "error"
});
@@ -137,7 +150,7 @@ export default function NotePreview({
width: "100%"
}}
>
<Dialog context="local" />
<Dialog context="preview" />
<DialogHeader
padding={12}
title={content?.title || note.title || session?.session}
@@ -185,7 +198,7 @@ export default function NotePreview({
<View
style={{
width: "100%",
height: 100,
flex: 1,
justifyContent: "center",
alignItems: "center"
}}

View File

@@ -112,7 +112,8 @@ const PayWall = (props: NavigationProps<"PayWall">) => {
}
setStep(Steps.buy);
}
}, [pricingPlans, routeParams.state]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [routeParams.state]);
useEffect(() => {
let listener: NativeEventSubscription;

View File

@@ -50,7 +50,7 @@ export const Synced = ({ item, close }) => {
close();
await sleep(300);
await openLinkInBrowser(
"https://help.notesnook.com/how-is-my-data-encrypted",
"https://notesnook.com/help/how-is-my-data-encrypted",
colors
);
} catch (e) {

View File

@@ -170,147 +170,147 @@ export const SelectionHeader = React.memo(
{!selectedItemsList.length
? null
: (focusedRouteId === "Monographs"
? [
{
title: strings.unpublish(),
onPress: () => {
presentDialog({
title: strings.doActions.unpublish.note(
selectedItemsList.length
),
positiveText: strings.unpublish(),
negativeText: strings.cancel(),
positivePress: async () => {
for (const id of selectedItemsList) {
await db.monographs.unpublish(id);
}
Navigation.queueRoutesForUpdate();
clearSelection();
},
positiveType: "errorShade"
});
},
visible: true,
icon: "delete"
}
]
: [
{
title: strings.manageTags(),
onPress: async () => {
ManageTags.present(selectedItemsList);
},
visible: type === "note",
icon: "pound"
},
{
title: strings.export(),
onPress: async () => {
ExportNotesSheet.present(selectedItemsList);
},
visible: type === "note",
icon: "export"
},
{
title: strings.addToNotebook(),
onPress: async () => {
Navigation.navigate("LinkNotebooks", {
noteIds: selectedItemsList
});
},
visible: type === "note",
icon: "plus"
},
{
title: strings.unlinkNotebook(),
onPress: async () => {
if (!id) return;
await db.notes.removeFromNotebook(
id,
...selectedItemsList
);
updateNotebook(id);
? [
{
title: strings.unpublish(),
onPress: () => {
presentDialog({
title: strings.doActions.unpublish.note(
selectedItemsList.length
),
positiveText: strings.unpublish(),
negativeText: strings.cancel(),
positivePress: async () => {
for (const id of selectedItemsList) {
await db.monographs.unpublish(id);
}
Navigation.queueRoutesForUpdate();
clearSelection();
},
visible: renderedInRoute === "Notebook",
icon: "minus"
},
{
title: strings.unfavorite(),
onPress: addToFavorite,
visible: focusedRouteId === "Favorites",
icon: "star-off"
},
{
title: strings.moveToTrash(),
onPress: async () => {
const selection = useSelectionStore.getState();
if (!selection.selectionMode) return;
await deleteItems(
selection.selectionMode as ItemType,
selection.selectedItemsList
);
selection.clearSelection();
selection.setSelectionMode(undefined);
},
visible: type === "note" || type === "notebook",
icon: "delete"
},
{
title: strings.doActions.delete.unknown(
type!,
selectedItemsList.length
),
onPress: async () => {
const selection = useSelectionStore.getState();
if (!selection.selectionMode) return;
await deleteItems(
selection.selectionMode as ItemType,
selection.selectedItemsList
);
selection.clearSelection();
selection.setSelectionMode(undefined);
},
visible:
type !== "trash" &&
type !== "note" &&
type !== "notebook",
icon: "delete"
},
{
title: strings.restore(),
onPress: restoreItem,
visible: type === "trash",
icon: "delete-restore"
},
{
title: strings.delete(),
onPress: deleteItem,
visible: type === "trash",
icon: "delete"
}
]
).map((item) =>
!item.visible ? null : (
<IconButton
size={AppFontSize.lg}
type="plain"
tooltipText={item.title}
tooltipPosition={NativeTooltip.POSITIONS.TOP}
testID={`select-${item.icon}`}
name={item.icon}
key={item.title}
color={colors.primary.icon}
onPress={async () => {
//@ts-ignore
menuRef.current?.hide();
if (Platform.OS === "ios") await sleep(300);
item.onPress();
}}
/>
)
)}
positiveType: "errorShade"
});
},
visible: true,
icon: "delete"
}
]
: [
{
title: strings.manageTags(),
onPress: async () => {
ManageTags.present(selectedItemsList);
},
visible: type === "note",
icon: "pound"
},
{
title: strings.export(),
onPress: async () => {
ExportNotesSheet.present(selectedItemsList);
},
visible: type === "note",
icon: "export"
},
{
title: strings.addToNotebook(),
onPress: async () => {
Navigation.navigate("LinkNotebooks", {
noteIds: selectedItemsList
});
},
visible: type === "note",
icon: "plus"
},
{
title: strings.unlinkNotebook(),
onPress: async () => {
if (!id) return;
await db.notes.removeFromNotebook(
id,
...selectedItemsList
);
updateNotebook(id);
Navigation.queueRoutesForUpdate();
clearSelection();
},
visible: renderedInRoute === "Notebook",
icon: "minus"
},
{
title: strings.unfavorite(),
onPress: addToFavorite,
visible: focusedRouteId === "Favorites",
icon: "star-off"
},
{
title: strings.moveToTrash(),
onPress: async () => {
const selection = useSelectionStore.getState();
if (!selection.selectionMode) return;
await deleteItems(
selection.selectionMode as ItemType,
selection.selectedItemsList
);
selection.clearSelection();
selection.setSelectionMode(undefined);
},
visible: type === "note" || type === "notebook",
icon: "delete"
},
{
title: strings.doActions.delete.unknown(
type!,
selectedItemsList.length
),
onPress: async () => {
const selection = useSelectionStore.getState();
if (!selection.selectionMode) return;
await deleteItems(
selection.selectionMode as ItemType,
selection.selectedItemsList
);
selection.clearSelection();
selection.setSelectionMode(undefined);
},
visible:
type !== "trash" &&
type !== "note" &&
type !== "notebook",
icon: "delete"
},
{
title: strings.restore(),
onPress: restoreItem,
visible: type === "trash",
icon: "delete-restore"
},
{
title: strings.delete(),
onPress: deleteItem,
visible: type === "trash",
icon: "delete"
}
]
).map((item) =>
!item.visible ? null : (
<IconButton
size={AppFontSize.lg}
type="plain"
tooltipText={item.title}
tooltipPosition={NativeTooltip.POSITIONS.TOP}
testID={`select-${item.icon}`}
name={item.icon}
key={item.title}
color={colors.primary.icon}
onPress={async () => {
//@ts-ignore
menuRef.current?.hide();
if (Platform.OS === "ios") await sleep(300);
item.onPress();
}}
/>
)
)}
</View>
<IconButton

View File

@@ -28,7 +28,7 @@ import { NativeEvents } from "@notesnook/editor-mobile/src/utils/native-events";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import React, { useEffect, useRef, useState } from "react";
import { TextInput, View } from "react-native";
import { ActivityIndicator, TextInput, View } from "react-native";
import { FlatList } from "react-native-actions-sheet";
import { db } from "../../../common/database";
import { useDBItem } from "../../../hooks/use-db-item";
@@ -40,6 +40,8 @@ import { Button } from "../../ui/button";
import Input from "../../ui/input";
import { Pressable } from "../../ui/pressable";
import Paragraph from "../../ui/typography/paragraph";
import Navigation from "../../../services/navigation";
import { useUserStore } from "../../../stores/use-user-store";
const ListNoteItem = ({
id,
@@ -118,8 +120,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
@@ -158,6 +160,7 @@ export default function LinkNote(props: {
const [selectedNote, setSelectedNote] = useState<Note>();
const [selectedNodeId, setSelectedNodeId] = useState<string>();
const [blocksLoading, setBlocksLoading] = useState(false);
useEffect(() => {
db.notes.all.sorted(db.settings.getGroupOptions("notes")).then((notes) => {
@@ -206,10 +209,12 @@ export default function LinkNote(props: {
const onSelectNote = async (note: Note) => {
setSelectedNote(note);
setBlocksLoading(true);
inputRef.current?.clear();
setTimeout(async () => {
nodesRef.current = await db.notes.contentBlocks(note.id);
setNodes(nodesRef.current);
setBlocksLoading(false);
});
// Fetch and set note's nodes.
};
@@ -224,6 +229,7 @@ export default function LinkNote(props: {
<View
style={{
paddingHorizontal: DefaultAppStyles.GAP,
paddingBottom: DefaultAppStyles.GAP,
minHeight: "100%",
maxHeight: "100%"
}}
@@ -330,28 +336,42 @@ export default function LinkNote(props: {
windowSize={3}
keyExtractor={(item) => item.id}
ListEmptyComponent={
<View
style={{
gap: DefaultAppStyles.GAP_VERTICAL,
backgroundColor: colors.secondary.background,
padding: DefaultAppStyles.GAP,
borderRadius: defaultBorderRadius,
borderWidth: 0.5,
borderColor: colors.secondary.border,
alignItems: "center"
}}
>
<Paragraph color={colors.secondary.paragraph}>
{blockLinking?.error}
</Paragraph>
<Button
title={strings.upgradePlan()}
!blockLinking || blocksLoading ? (
<ActivityIndicator size={25} color={colors.primary.accent} />
) : !blockLinking.isAllowed ? (
<View
style={{
width: "100%"
gap: DefaultAppStyles.GAP_VERTICAL,
backgroundColor: colors.secondary.background,
padding: DefaultAppStyles.GAP,
borderRadius: defaultBorderRadius,
borderWidth: 0.5,
borderColor: colors.secondary.border,
alignItems: "center"
}}
type="accent"
/>
</View>
>
<Paragraph color={colors.secondary.paragraph}>
{blockLinking?.error}
</Paragraph>
<Button
title={strings.upgradePlan()}
style={{
width: "100%"
}}
type="accent"
onPress={() => {
Navigation.navigate("PayWall", {
context: useUserStore.getState().user
? "logged-in"
: "logged-out",
canGoBack: true
});
props.close?.();
}}
/>
</View>
) : null
}
data={blockLinking?.isAllowed ? nodes : []}
/>

View File

@@ -509,7 +509,7 @@ const PublishNoteSheet = ({
onPress={async () => {
try {
await openLinkInBrowser(
"https://help.notesnook.com/publish-notes-with-monographs"
"https://notesnook.com/help/publish-notes-with-monographs"
);
} catch (e) {
console.error(e);

View File

@@ -230,7 +230,7 @@ class RecoveryKeySheet extends React.Component {
>
<Paragraph
color={colors.primary.paragraph}
size={AppFontSize.sm}
size={AppFontSize.md}
numberOfLines={2}
selectable
style={{
@@ -238,7 +238,9 @@ class RecoveryKeySheet extends React.Component {
maxWidth: "100%",
paddingRight: 10,
textAlign: "center",
textDecorationLine: "underline"
textDecorationLine: "underline",
letterSpacing: 0.5,
fontFamily: "monospace"
}}
>
{this.state.key}

View File

@@ -33,7 +33,6 @@ import {
setGroupOptionsById
} from "../../../hooks/use-group-options";
import { eSendEvent } from "../../../services/event-manager";
import Navigation from "../../../services/navigation";
import { RouteName } from "../../../stores/use-navigation-store";
import { useNotebookStore } from "../../../stores/use-notebook-store";
import { useTagStore } from "../../../stores/use-tag-store";
@@ -46,6 +45,7 @@ import { Button } from "../../ui/button";
import { Pressable } from "../../ui/pressable";
import Heading from "../../ui/typography/heading";
import Paragraph from "../../ui/typography/paragraph";
import Navigation from "../../../services/navigation";
const Sort = ({
dataType,
screen,
@@ -87,8 +87,7 @@ const Sort = ({
};
const updateGroupOptions = async (_groupOptions: GroupOptions) => {
console.log(groupId, type);
setGroupOptionsById(groupType, _groupOptions, groupId, type);
await setGroupOptionsById(groupType, _groupOptions, groupId, type);
setGroupOptions(_groupOptions);
setTimeout(() => {
if (screen) Navigation.queueRoutesForUpdate(screen);

View File

@@ -30,6 +30,7 @@ import { Pressable } from "../ui/pressable";
import { SvgView } from "../ui/svg";
import Heading from "../ui/typography/heading";
import { useSideBarDraggingStore } from "./dragging-store";
import SyncStatusButton from "./sync-status-button";
const SettingsIcon = () => {
const { colors } = useThemeColors();
@@ -123,7 +124,7 @@ export const SideMenuHeader = (props: { rightButtons?: IconButtonProps[] }) => {
size={AppFontSize.lg}
/>
))}
<SyncStatusButton />
<SettingsIcon />
</View>
</View>

View File

@@ -0,0 +1,161 @@
/*
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 { useTimeAgo } from "@notesnook/common";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import { useNetInfo } from "@react-native-community/netinfo";
import React from "react";
import { View } from "react-native";
import Animated, {
Easing,
cancelAnimation,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming
} from "react-native-reanimated";
import Sync from "../../services/sync";
import { SyncStatus, useUserStore } from "../../stores/use-user-store";
import { AppFontSize } from "../../utils/size";
import NativeTooltip from "../../utils/tooltip";
import AppIcon from "../ui/AppIcon";
import { IconButton } from "../ui/icon-button";
const SyncStatusButton = () => {
const { colors } = useThemeColors();
const [user, syncing, lastSyncStatus, lastSynced, isLoggingOut] =
useUserStore((state) => [
state.user,
state.syncing,
state.lastSyncStatus,
state.lastSynced,
state.isLoggingOut
]);
const { isInternetReachable } = useNetInfo();
const isOffline = !isInternetReachable;
const hasSyncedBefore = lastSynced && lastSynced !== "Never";
const isFailed = lastSyncStatus === SyncStatus.Failed;
const isSynced = lastSyncStatus === SyncStatus.Passed;
const lastSyncedTimeAgo = useTimeAgo(lastSynced, {
interval: 5000,
live: true
});
const getIconColor = (): string => {
if (syncing) return colors.primary.accent;
if (isSynced) return colors.primary.accent;
return colors.secondary.icon;
};
const rotation = useSharedValue(0);
React.useEffect(() => {
if (syncing && !isOffline) {
rotation.value = 0;
rotation.value = withRepeat(
withTiming(360, { duration: 1000, easing: Easing.linear }),
-1,
false
);
} else {
cancelAnimation(rotation);
rotation.value = withTiming(360, {
duration: 1000 * (1 - (rotation.value % 360) / 360),
easing: Easing.linear
});
}
}, [syncing, rotation, isOffline]);
const rotationStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }]
}));
const tooltipText = React.useMemo(() => {
const offlineSuffix = isOffline ? ` (${strings.offline()})` : "";
if (syncing) return strings.syncing();
if (!hasSyncedBefore) return `${strings.never()}${offlineSuffix}`;
if (isFailed) return `${strings.syncFailed()}${offlineSuffix}`;
return `${strings.synced()}${lastSyncedTimeAgo}${offlineSuffix ? `${offlineSuffix}` : ""}`;
}, [isOffline, syncing, hasSyncedBefore, isFailed, lastSyncedTimeAgo]);
const onPress = () => {
if (syncing) return;
Sync.run();
};
if (!user || isLoggingOut) return null;
return (
<View
style={{
justifyContent: "center",
alignItems: "center",
position: "relative"
}}
>
{syncing ? (
<View
style={{
width: 28,
height: 28,
borderRadius: 6,
borderWidth: 1,
borderColor: colors.primary.border,
justifyContent: "center",
alignItems: "center"
}}
>
<Animated.View style={rotationStyle}>
<AppIcon name="sync" size={AppFontSize.lg} color={getIconColor()} />
</Animated.View>
</View>
) : (
<IconButton
name="sync"
onPress={onPress}
tooltipText={tooltipText}
tooltipPosition={NativeTooltip.POSITIONS.BOTTOM}
size={AppFontSize.lg}
color={getIconColor()}
style={{
width: 28,
height: 28,
borderRadius: 6,
borderWidth: 1,
borderColor: colors.primary.border
}}
/>
)}
{!syncing && (isFailed || isOffline) ? (
<AppIcon
name="information"
color={isOffline ? colors.static.yellow : colors.error.icon}
size={AppFontSize.xxs}
style={{ position: "absolute", bottom: -3, right: -3 }}
/>
) : null}
</View>
);
};
export default SyncStatusButton;

View File

@@ -27,7 +27,7 @@ import { useUserStore } from "../../../stores/use-user-store";
import { getContainerBorder } from "../../../utils/colors";
import { NotesnookModule } from "../../../utils/notesnook-module";
import { Toast } from "../../toast";
import { useReduceMotion } from "../../../hooks/use-reduce-motion";
/**
*
* @param {any} param0
@@ -53,6 +53,9 @@ const SheetWrapper = ({
const sheetKeyboardHandler = useSettingStore(
(state) => state.sheetKeyboardHandler
);
const isReduceMotionEnabled = useReduceMotion();
const isAnimated = !isReduceMotionEnabled;
const largeTablet = deviceMode === "tablet";
const smallTablet = deviceMode === "smallTablet";
const dimensions = useSettingStore((state) => state.dimensions);
@@ -125,6 +128,7 @@ const SheetWrapper = ({
<ScopedThemeProvider value="sheet">
<ActionSheet
ref={fwdRef || localRef}
animated={isAnimated}
testIDs={{
backdrop: "sheet-backdrop"
}}

View File

@@ -81,6 +81,7 @@ import {
setUpdateAvailableMessage
} from "../services/message";
import Navigation from "../services/navigation";
import { NotePreviewWidget } from "../services/note-preview-widget";
import Notifications from "../services/notifications";
import PremiumService from "../services/premium";
import SettingsService from "../services/settings";
@@ -345,6 +346,7 @@ const onLogout = async (reason: string) => {
SettingsService.resetSettings();
useUserStore.getState().setUser(null);
useUserStore.getState().setSyncing(false);
useUserStore.getState().setIsLoggingOut(false);
eSendEvent(eAfterSync);
};
@@ -574,6 +576,11 @@ export const useAppEvents = () => {
useEffect(() => {
if (isAppLoading) return;
// Widgets outlive the app process, so they can be left showing content the app no longer has
// (most obviously after the user clears app data). Nothing can run at that moment, so the
// first launch afterwards is the earliest chance to put them right.
NotePreviewWidget.updateNotes();
let subscriptions: EventManagerSubscription[] = [];
const eventManager = db.eventManager;
subscriptions = [

View File

@@ -54,15 +54,13 @@ export function useGroupOptions(
const [groupOptions, setGroupOptions] = useState(
getGroupOptions(groupingKey, id, type)
);
console.log(groupingKey, id, type, groupOptions, "options");
const groupOptionsRef = useRef(groupOptions);
groupOptionsRef.current = groupOptions;
useEffect(() => {
const onUpdate = (_groupingKey: string, _id?: string, _type?: string) => {
if (_groupingKey !== groupingKey) return;
if (_id && _type && _id !== id && _type !== type) return;
if (_groupingKey !== groupingKey || _type !== type) return;
if (_id && _type && _id !== id) return;
const options = getGroupOptions(groupingKey, id, type);
if (!options) return;
if (
@@ -70,9 +68,7 @@ export function useGroupOptions(
groupOptionsRef.current?.sortBy !== options.sortBy ||
groupOptionsRef.current?.sortDirection !== options?.sortDirection
) {
console.log("onUpdate", _id, _type);
setGroupOptions({ ...options });
Navigation.queueRoutesForUpdate();
}
};

View File

@@ -0,0 +1,50 @@
/*
This file is part of the Notesnook project (https://notesnook.com/)
Copyright (C) 2023 Streetwriters (Private) Limited
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import { useEffect, useState } from "react";
import { AccessibilityInfo } from "react-native";
export function useReduceMotion(): boolean {
const [isReduceMotionEnabled, setIsReduceMotionEnabled] =
useState<boolean>(false);
useEffect(() => {
AccessibilityInfo.isReduceMotionEnabled()
.then(setIsReduceMotionEnabled)
.catch(() => {});
const subscription = AccessibilityInfo.addEventListener(
"reduceMotionChanged",
setIsReduceMotionEnabled
);
return () => {
if (subscription?.remove) {
subscription.remove();
} else if ((AccessibilityInfo as any).removeEventListener) {
(AccessibilityInfo as any).removeEventListener(
"reduceMotionChanged",
setIsReduceMotionEnabled
);
}
};
}, []);
return isReduceMotionEnabled;
}

View File

@@ -17,54 +17,67 @@ 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 Shortcuts, { ShortcutItem } from "react-native-actions-shortcuts";
import { useEffect } from "react";
import { NativeEventEmitter, NativeModule } from "react-native";
import { useRef } from "react";
import { Platform } from "react-native";
import { NativeEventEmitter, NativeModule, Platform } from "react-native";
import deviceInfoModule from "react-native-device-info";
import { strings } from "@notesnook/intl";
import { useSettingStore } from "../stores/use-setting-store";
import { useTabStore } from "../screens/editor/tiptap/use-tab-store";
const ShortcutsEmitter = new NativeEventEmitter(
Shortcuts as unknown as NativeModule
);
function isSupported() {
export function isShortcutsSupported() {
return Platform.OS !== "android" || deviceInfoModule.getApiLevelSync() > 25;
}
const defaultShortcuts: ShortcutItem[] = [
{
type: "notesnook.action.newnote",
title: strings.createNewNote(),
shortTitle: strings.newNote(),
iconName: Platform.OS === "android" ? "ic_newnote" : "plus"
},
{
type: "notesnook.action.newreminder",
title: strings.setReminder(),
shortTitle: strings.newReminder(),
iconName: Platform.OS === "android" ? "ic_newnote" : "plus"
}
];
export const useShortcutManager = ({
onShortcutPressed,
shortcuts = defaultShortcuts
}: {
onShortcutPressed: (shortcut: ShortcutItem | null) => void;
shortcuts?: ShortcutItem[];
}) => {
const initialShortcutRecieved = useRef(false);
useEffect(() => {
if (!isSupported()) return;
Shortcuts.setShortcuts(shortcuts);
}, [shortcuts]);
export function registerAppShortcuts(
shortcuts: ShortcutItem[] = defaultShortcuts
) {
if (!isShortcutsSupported()) return;
Shortcuts.setShortcuts(shortcuts);
}
useEffect(() => {
if (!isSupported()) return;
Shortcuts.getInitialShortcut().then((shortcut) => {
if (initialShortcutRecieved.current || !shortcut) return;
onShortcutPressed(shortcut);
initialShortcutRecieved.current = true;
});
const subscription = ShortcutsEmitter.addListener(
"onShortcutItemPressed",
onShortcutPressed
);
return () => {
subscription?.remove();
};
}, [onShortcutPressed]);
};
let listenerInitialized = false;
export function initShortcutListener() {
if (!isShortcutsSupported() || listenerInitialized) return;
listenerInitialized = true;
ShortcutsEmitter.addListener(
"onShortcutItemPressed",
(shortcut: ShortcutItem) => {
console.time("shortcut");
useSettingStore.setState({ pendingShortcut: shortcut });
}
);
}
export function launchNewNoteTab() {
let tabId;
const currentTab = useTabStore
.getState()
.getTab(useTabStore.getState().currentTab as string);
if (useTabStore.getState().tabs.length === 0 || currentTab?.pinned) {
tabId = useTabStore.getState().newTab();
} else {
tabId = useTabStore.getState().currentTab;
if (useTabStore.getState().getTab(tabId)?.session?.noteId) {
useTabStore.getState().newTabSession(tabId, {});
}
}
}

View File

@@ -41,10 +41,9 @@ import Animated, {
} from "react-native-reanimated";
import { notesnook } from "../../e2e/test.ids";
import { db } from "../common/database";
import { FluidPanels } from "../components/fluid-panels";
import { FluidPanels, FluidTabPage } from "../components/fluid-panels";
import { useSideBarDraggingStore } from "../components/side-menu/dragging-store";
import useGlobalSafeAreaInsets from "../hooks/use-global-safe-area-insets";
import { useShortcutManager } from "../hooks/use-shortcut-manager";
import { hideAllTooltips } from "../hooks/use-tooltip";
import { useTabStore } from "../screens/editor/tiptap/use-tab-store";
import { editorController, editorState } from "../screens/editor/tiptap/utils";
@@ -59,7 +58,6 @@ import {
eCloseFullscreenEditor,
eOnEnterEditor,
eOnExitEditor,
eOnLoadNote,
eOpenFullscreenEditor,
eUnlockNote
} from "../utils/events";
@@ -67,6 +65,7 @@ import { valueLimiter } from "../utils/functions";
import { fluidTabsRef } from "../utils/global-refs";
import { AppNavigationStack } from "./navigation-stack";
import type { PaneWidths } from "../screens/editor/wrapper";
import { NavigationProps } from "../services/navigation";
const MOBILE_SIDEBAR_SIZE = 0.85;
@@ -74,7 +73,7 @@ let SideMenu: any = null;
let EditorWrapper: any = null;
export const FluidPanelsView = React.memo(
() => {
({ route }: NavigationProps<"FluidPanelsView">) => {
const { colors } = useThemeColors();
const deviceMode = useSettingStore((state) => state.deviceMode);
const setFullscreen = useSettingStore((state) => state.setFullscreen);
@@ -102,6 +101,15 @@ export const FluidPanelsView = React.memo(
setOrientation(o);
}
});
React.useEffect(() => {
const shortcut = useSettingStore.getState().pendingShortcut;
if (shortcut?.type === "notesnook.action.newnote") {
useSettingStore.setState({
pendingShortcut: null
});
}
}, []);
useEffect(() => {
if (!appLoading) {
@@ -111,29 +119,6 @@ export const FluidPanelsView = React.memo(
}
}, [appLoading]);
useShortcutManager({
onShortcutPressed: async (item) => {
if (!item) return;
if (item?.type === "notesnook.action.newnote") {
if (!fluidTabsRef.current) {
setTimeout(() => {
eSendEvent(eOnLoadNote, { newNote: true });
editorState().movedAway = false;
fluidTabsRef.current?.goToPage("editor", false);
}, 1000);
return;
}
eSendEvent(eOnLoadNote, { newNote: true });
editorState().movedAway = false;
setTimeout(
() => fluidTabsRef.current?.goToPage("editor", false),
300
);
}
}
});
const showFullScreenEditor = useCallback(() => {
setFullscreen(true);
if (deviceMode === "smallTablet") {
@@ -356,6 +341,7 @@ export const FluidPanelsView = React.memo(
dimensions={dimensions}
widths={PANE_WIDTHS[deviceMode as keyof typeof PANE_WIDTHS]}
enabled={deviceMode !== "tablet" && !fullscreen}
initialPage={route.params?.initialPage}
onScroll={onScroll}
onChangeTab={onChangeTab}
onDrawerStateChange={(state) => {
@@ -447,7 +433,9 @@ const onChangeTab = async (event: { i: number; from: number }) => {
if (event.i === 2) {
editorState().movedAway = false;
editorState().isFocused = true;
activateKeepAwake();
if (useSettingStore.getState().settings.keepScreenOn) {
activateKeepAwake();
}
eSendEvent(eOnEnterEditor);
if (

View File

@@ -27,10 +27,17 @@ import useNavigationStore, {
} from "../stores/use-navigation-store";
import { useSelectionStore } from "../stores/use-selection-store";
import { useSettingStore } from "../stores/use-setting-store";
import { rootNavigatorRef } from "../utils/global-refs";
import { fluidTabsRef, rootNavigatorRef } from "../utils/global-refs";
import Navigation from "../services/navigation";
import { isFeatureAvailable } from "@notesnook/common";
import { isFeatureAvailable, useIsFeatureAvailable } from "@notesnook/common";
import { isInternalLink, parseInternalLink } from "@notesnook/core";
import { eSendEvent } from "../services/event-manager";
import { editorState } from "../screens/editor/tiptap/utils";
import { eOnLoadNote } from "../utils/events";
import { strings } from "@notesnook/intl";
import PaywallSheet from "../components/sheets/paywall";
import { presentDialog } from "../components/dialog/functions";
import { launchNewNoteTab } from "../hooks/use-shortcut-manager";
const RootStack = createNativeStackNavigator();
const AppStack = createNativeStackNavigator();
@@ -300,8 +307,15 @@ export const RootNavigation = () => {
const introCompleted = useSettingStore(
(state) => state.settings.introCompleted
);
const initialShortcut = React.useRef(
useSettingStore.getState().pendingShortcut
).current;
const reminderFeature = useIsFeatureAvailable("activeReminders");
const clearSelection = useSelectionStore((state) => state.clearSelection);
const resetTimer = React.useRef<NodeJS.Timeout>(undefined);
const onStateChange = React.useCallback(
(state: any) => {
if (useSelectionStore.getState().selectionMode) {
@@ -316,13 +330,66 @@ export const RootNavigation = () => {
[clearSelection]
);
React.useEffect(() => {
const unsubscribe = useSettingStore.subscribe((state, prevState) => {
const pendingShortcut = state.pendingShortcut;
if (pendingShortcut === prevState.pendingShortcut || !pendingShortcut) {
return;
}
if (pendingShortcut.type === "notesnook.action.newreminder") {
if (reminderFeature === undefined) return;
if (!reminderFeature.isAllowed) {
presentDialog({
title: strings.upgrade(),
paragraph: reminderFeature.error,
positiveText: strings.upgrade(),
negativeText: strings.cancel(),
positivePress: async () => {
PaywallSheet.present(reminderFeature);
}
});
useSettingStore.setState({
pendingShortcut: null
});
return;
}
rootNavigatorRef.current?.navigate("AddReminder" as any);
} else if (pendingShortcut.type === "notesnook.action.newnote") {
if (fluidTabsRef.current) {
rootNavigatorRef.current?.navigate("FluidPanelsView" as any);
eSendEvent(eOnLoadNote, { newNote: true });
editorState().movedAway = false;
fluidTabsRef.current.goToPage("editor", true);
} else {
launchNewNoteTab();
rootNavigatorRef.current?.navigate("FluidPanelsView" as any, {
initialPage: "editor"
});
}
}
});
return unsubscribe;
}, [reminderFeature]);
const initialRouteName = !introCompleted
? "Welcome"
: initialShortcut?.type === "notesnook.action.newreminder"
? "AddReminder"
: "FluidPanelsView";
return (
<NavigationContainer onStateChange={onStateChange} ref={rootNavigatorRef}>
<RootStack.Navigator
screenOptions={{
headerShown: false
}}
initialRouteName={introCompleted ? "FluidPanelsView" : "Welcome"}
initialRouteName={initialRouteName}
>
<RootStack.Screen
name="Welcome"
@@ -347,6 +414,12 @@ export const RootNavigation = () => {
require("../navigation/fluid-panels-view").default;
return FluidPanelsView;
}}
initialParams={{
initialPage:
initialShortcut?.type === "notesnook.action.newnote"
? "editor"
: undefined
}}
/>
<RootStack.Screen

View File

@@ -20,8 +20,9 @@ import { Note, Reminder } from "@notesnook/core";
import { strings } from "@notesnook/intl";
import { useThemeColors } from "@notesnook/theme";
import dayjs from "dayjs";
import React, { useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
BackHandler,
KeyboardAvoidingView,
Platform,
ScrollView,
@@ -63,6 +64,7 @@ import FormInput, {
validators
} from "../../components/ui/input/form-input";
import AppIcon from "../../components/ui/AppIcon";
import { presentDialog } from "../../components/dialog/functions";
const ReminderModes =
Platform.OS === "ios"
@@ -94,7 +96,7 @@ const ReminderNotificationModes = {
};
export default function AddReminder(props: NavigationProps<"AddReminder">) {
const { reminder, reference } = props.route.params;
const { reminder, reference } = props.route.params ?? {};
useNavigationFocus(props.navigation, {
focusOnInit: true,
onFocus: () => {
@@ -106,6 +108,23 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
return false;
}
});
const handleBackNavigation = useCallback(() => {
const routes = props.navigation.getState()?.routes;
if (routes && routes.length <= 1) {
props.navigation.navigate("FluidPanelsView" as any);
return true;
}
Navigation.goBack();
return true;
}, [props.navigation]);
useEffect(() => {
const sub = BackHandler.addEventListener("hardwareBackPress", () => {
return handleBackNavigation();
});
return () => sub.remove();
}, [handleBackNavigation]);
const { colors, isDark } = useThemeColors();
const weekFormat = useSettingStore((state) => state.weekFormat);
const [reminderMode, setReminderMode] = useState<Reminder["mode"]>(
@@ -127,6 +146,7 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
const [repeatFrequency, setRepeatFrequency] = useState(1);
const referencedItem = reference ? (reference as Note) : null;
const recurringReminderFeature = useIsFeatureAvailable("recurringReminders");
const activeReminderFeature = useIsFeatureAvailable("activeReminders");
const formRef = useRef(
createFormRef({
title: reminder?.title || referencedItem?.title || "",
@@ -153,6 +173,31 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
);
const [dateError, setDateError] = useState<string>();
const [selectDayError, setSelectDayError] = useState<string>();
React.useEffect(() => {
const shortcut = useSettingStore.getState().pendingShortcut;
if (shortcut?.type === "notesnook.action.newreminder") {
useSettingStore.setState({
pendingShortcut: null
});
}
}, []);
useEffect(() => {
if (activeReminderFeature === undefined) return;
if (!activeReminderFeature.isAllowed) {
presentDialog({
title: strings.upgrade(),
paragraph: activeReminderFeature.error,
positiveText: strings.upgrade(),
negativeText: strings.cancel(),
positivePress: async () => {
PaywallSheet.present(activeReminderFeature);
},
onClose: () => {
props.navigation.navigate("FluidPanelsView" as any);
}
});
}
}, [activeReminderFeature]);
const showDatePicker = () => {
setDatePickerVisibility(true);
@@ -243,7 +288,7 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
Notifications.scheduleNotification(_reminder as Reminder);
Navigation.queueRoutesForUpdate();
useRelationStore.getState().update();
Navigation.goBack();
handleBackNavigation();
} catch (e) {
ToastManager.error(e as Error, undefined);
}
@@ -267,12 +312,12 @@ export default function AddReminder(props: NavigationProps<"AddReminder">) {
<Header
title={reminder ? strings.editReminder() : strings.newReminder()}
canGoBack
onLeftMenuButtonPress={handleBackNavigation}
rightButton={{
name: "check",
onPress: saveReminder
}}
/>
<Dialog context="local" />
<ScrollView
style={{
marginBottom: DDS.isTab ? 25 : undefined,

View File

@@ -90,9 +90,10 @@ export type SavePayload = {
data?: string;
type?: "tiptap";
sessionHistoryId?: number;
ignoreEdit: boolean;
tabId: string;
pendingChanges?: boolean;
sourceNoteId?: string;
pendingChangesAt?: number;
};
export type AppState = {

View File

@@ -149,7 +149,7 @@ const showActionsheet = async () => {
}
};
type ContentMessage = { html: string; ignoreEdit: boolean };
type ContentMessage = { html: string };
export const useEditorEvents = (
editor: useEditorType,
@@ -411,16 +411,19 @@ export const useEditorEvents = (
.getState()
.getNoteIdForTab(editorMessage.tabId);
const saveNoteId = editorMessage.noteId || noteId;
switch (editorMessage.type) {
case EditorEvents.content:
DatabaseLogger.log("EditorEvents.content");
editor.saveContent({
type: editorMessage.type,
content: editorMessage.value.html as string,
noteId: noteId,
noteId: saveNoteId,
sourceNoteId: editorMessage.noteId,
tabId: editorMessage.tabId,
ignoreEdit: (editorMessage.value as ContentMessage).ignoreEdit,
pendingChanges: editorMessage.value?.pendingChanges
pendingChanges: editorMessage.value?.pendingChanges,
pendingChangesAt: editorMessage.value?.pendingChangesAt
});
break;
case EditorEvents.title:
@@ -428,10 +431,11 @@ export const useEditorEvents = (
editor.saveContent({
type: editorMessage.type,
title: editorMessage.value?.title as string,
noteId: noteId,
noteId: saveNoteId,
sourceNoteId: editorMessage.noteId,
tabId: editorMessage.tabId,
ignoreEdit: false,
pendingChanges: editorMessage.value?.pendingChanges
pendingChanges: editorMessage.value?.pendingChanges,
pendingChangesAt: editorMessage.value?.pendingChangesAt
});
break;
case EditorEvents.logger:

View File

@@ -260,29 +260,65 @@ export const useEditor = (
id,
data,
type,
ignoreEdit,
sessionHistoryId: currentSessionHistoryId,
tabId,
pendingChanges
pendingChanges,
sourceNoteId,
pendingChangesAt
}: SavePayload) => {
if (currentNotes.current[id as string]?.readonly || readonly) return;
if (sourceNoteId && id && sourceNoteId !== id) {
DatabaseLogger.error(
new Error(
`Refused to save content of note ${sourceNoteId} into note ${id}`
)
);
return;
}
try {
if (id && !(await db.notes?.note(id))) {
await reset(tabId);
useTabStore.getState().updateTab(tabId, {
session: {
noteId: undefined,
noteLocked: undefined,
locked: undefined,
readonly: undefined,
scrollTop: undefined,
selection: undefined,
spellCheckDisabled: false
}
});
if (useTabStore.getState().getNoteIdForTab(tabId) === id) {
await reset(tabId);
useTabStore.getState().updateTab(tabId, {
session: {
noteId: undefined,
noteLocked: undefined,
locked: undefined,
readonly: undefined,
scrollTop: undefined,
selection: undefined,
spellCheckDisabled: false
}
});
}
return;
}
let note = id ? await db.notes?.note(id) : undefined;
// A restored pending change can be older than what is already in the
// db (it was saved on another device, or the save actually went
// through and only the acknowledgement was lost). Applying it would
// roll the note back, so verify it is still the newest edit. Content
// and title are compared separately so that a newer title doesn't
// discard pending content, and vice versa.
if (pendingChanges && pendingChangesAt && note) {
const dateEdited = data
? note.contentId
? (await db.content?.get(note.contentId))?.dateEdited
: undefined
: note.dateEdited;
if (dateEdited && dateEdited > pendingChangesAt) {
DatabaseLogger.log(
`Discarding stale pending ${
data ? "content" : "title"
} for note ${id}: edited at ${dateEdited}, change captured at ${pendingChangesAt}`
);
return id;
}
}
const locked = note && (await db.vaults.itemExists(note));
if (note?.conflicted) {
@@ -306,11 +342,6 @@ export const useEditor = (
noteData.title = title;
if (ignoreEdit) {
DatabaseLogger.log("Ignoring edits...");
noteData.dateEdited = note?.dateEdited;
}
if (data) {
noteData.content = {
data: data,
@@ -321,6 +352,9 @@ export const useEditor = (
let saved = false;
setTimeout(() => {
if (saved) return;
// Don't report progress on a tab that has moved on to another note.
if (id && useTabStore.getState().getNoteIdForTab(tabId) !== id)
return;
commands.setStatus(
getFormattedDate(note ? note.dateEdited : Date.now(), "date-time"),
strings.saving(),
@@ -436,14 +470,26 @@ export const useEditor = (
}
}
if (
id &&
id === useTabStore.getState().getCurrentNoteId() &&
pendingChanges
) {
postMessage(NativeEvents.title, title || note?.title, tabId);
postMessage(NativeEvents.html, data, tabId);
currentNotes.current[id] = note;
if (id && pendingChanges) {
if (data) {
currentContents.current[id] = {
data: data,
type: "tiptap",
noteId: id
};
}
lastContentChangeTime.current[id] = Date.now();
// Push the restored change into the editor only if the note is
// actually open in a tab, and only into that tab.
const noteTabId = useTabStore.getState().getTabForNote(id);
if (noteTabId !== undefined) {
postMessage(NativeEvents.title, title || note?.title, noteTabId);
if (data) {
postMessage(NativeEvents.html, { data: data }, noteTabId);
}
currentNotes.current[id] = note;
}
}
if (!saveCount.current[tabId]) {
@@ -703,12 +749,13 @@ export const useEditor = (
await postMessage(NativeEvents.title, item.title, tabId);
overlay(false);
const updatedTab = useTabStore.getState().getTab(tabId!);
await postMessage(
NativeEvents.html,
{
data: currentContents.current[item.id]?.data || "",
scrollTop: tab?.session?.scrollTop,
selection: tab?.session?.selection,
scrollTop: updatedTab?.session?.scrollTop,
selection: updatedTab?.session?.selection,
searchResultIndex: event.searchResultIndex
},
tabId,
@@ -932,24 +979,25 @@ export const useEditor = (
title,
content,
type,
ignoreEdit,
noteId,
tabId,
pendingChanges
pendingChanges,
sourceNoteId,
pendingChangesAt
}: {
noteId?: string;
title?: string;
content?: string;
type: string;
ignoreEdit: boolean;
tabId: string;
pendingChanges?: boolean;
sourceNoteId?: string;
pendingChangesAt?: number;
}) => {
DatabaseLogger.log(
`saveContent... title: ${!!title}, content: ${!!content}, noteId: ${noteId}`
);
if (
ignoreEdit ||
lock.current ||
(currentLoadingNoteId.current &&
currentLoadingNoteId.current === noteId)
@@ -958,7 +1006,6 @@ export const useEditor = (
lock.current: ${lock.current}
currentLoadingNoteId.current: ${currentLoadingNoteId.current}
ignoreEdit: ${ignoreEdit}
`);
if (lock.current) {
setTimeout(() => {
@@ -971,7 +1018,10 @@ export const useEditor = (
return;
}
if (noteId) {
// A restored pending change is not a live edit: it may still be
// discarded as stale by saveNote, so it must not claim to be the newest
// content until it is actually written.
if (noteId && !pendingChanges) {
lastContentChangeTime.current[noteId] = Date.now();
localTabState.current?.setEditTime(noteId, Date.now());
localTabState?.current?.set(tabId, {
@@ -979,7 +1029,7 @@ export const useEditor = (
});
}
if (type === EditorEvents.content && noteId) {
if (type === EditorEvents.content && noteId && !pendingChanges) {
currentContents.current[noteId as string] = {
data: content,
type: "tiptap",
@@ -992,15 +1042,17 @@ export const useEditor = (
data: content,
type: "tiptap",
id: noteId,
ignoreEdit,
sessionHistoryId: noteId ? editorSessionHistory.get(noteId) : undefined,
tabId: tabId,
pendingChanges
pendingChanges,
sourceNoteId,
pendingChangesAt
};
withTimer(
noteId || "newnote",
`${noteId || tabId}:${type}`,
() => {
if (!params.id) {
if (!params.id && !params.sourceNoteId) {
params.id = useTabStore.getState().getNoteIdForTab(tabId);
}
if (onChange && params.data) {
@@ -1018,7 +1070,7 @@ export const useEditor = (
saveNote(params);
}
},
ignoreEdit ? 0 : 150
150
);
},
[editorSessionHistory, withTimer, onChange, saveNote]

View File

@@ -30,6 +30,7 @@ import SettingsService from "../../services/settings";
import useNavigationStore from "../../stores/use-navigation-store";
import { useNotes } from "../../stores/use-notes-store";
import { openEditor } from "../notes/common";
import { db } from "../../common/database";
export const Home = ({ navigation, route }: NavigationProps<"Notes">) => {
const [notes, loading] = useNotes();
@@ -59,7 +60,8 @@ export const Home = ({ navigation, route }: NavigationProps<"Notes">) => {
placeholder: strings.searchInRoute(route.name),
type: "note",
title: route.name,
route: route.name
route: route.name,
items: db.notes.all
});
}}
id={route.name}

View File

@@ -35,7 +35,10 @@ import Paragraph from "../../components/ui/typography/paragraph";
import { useDBItem } from "../../hooks/use-db-item";
import { useNavigationFocus } from "../../hooks/use-navigation-focus";
import Navigation, { NavigationProps } from "../../services/navigation";
import { createItemSelectionStore } from "../../stores/item-selection-store";
import {
createItemSelectionStore,
SelectionState
} from "../../stores/item-selection-store";
import { updateNotebook } from "../../utils/notebooks";
import { AppFontSize } from "../../utils/size";
import { DefaultAppStyles } from "../../utils/styles";
@@ -47,14 +50,12 @@ export const MoveNotes = (props: NavigationProps<"MoveNotes">) => {
const currentNotebook = props.route.params.notebook;
const inputRef = useRef<TextInput>(null);
const [loading, setLoading] = useState(false);
const selectionCount = useItemSelectionStore(
(state) =>
Object.keys(state.selection).filter(
(k) =>
state.selection?.[k] === "selected" ||
state.selection[k] === "deselected"
)?.length > 0
);
const hasPendingChanges = useItemSelectionStore((state) => {
return Object.keys(state.selection).some((id) => {
return state.selection[id] !== state.initialState[id];
});
});
useNavigationFocus(props.navigation, { focusOnInit: true });
@@ -76,20 +77,6 @@ export const MoveNotes = (props: NavigationProps<"MoveNotes">) => {
.catch(() => {
setLoading(false);
});
db.relations
.from(currentNotebook, "note")
.get()
.then((existingNotes) => {
const selection: { [name: string]: any } = {};
existingNotes.forEach((rel) => {
selection[rel.toId] = "selected";
});
useItemSelectionStore.setState({
selection: selection,
initialState: selection
});
});
},
[currentNotebook]
);
@@ -97,6 +84,25 @@ export const MoveNotes = (props: NavigationProps<"MoveNotes">) => {
useEffect(() => {
loadNotes();
const loadSelection = async () => {
const existingNotes = await db.relations
.from(currentNotebook, "note")
.get();
const selection: Record<string, SelectionState> = {};
existingNotes.forEach((rel) => {
selection[rel.toId] = "selected";
});
useItemSelectionStore.setState({
selection,
initialState: selection
});
};
loadSelection();
return () => {
useItemSelectionStore.getState().reset();
};
@@ -159,6 +165,8 @@ export const MoveNotes = (props: NavigationProps<"MoveNotes">) => {
/>
<FlatList
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
ListEmptyComponent={
<View
style={{
@@ -176,17 +184,19 @@ export const MoveNotes = (props: NavigationProps<"MoveNotes">) => {
)}
</View>
}
style={{
flexGrow: 1
style={{ flex: 1 }}
contentContainerStyle={{
paddingBottom: hasPendingChanges ? 80 : 0
}}
data={loading ? [] : notes?.placeholders}
renderItem={renderItem}
/>
{selectionCount ? (
{hasPendingChanges ? (
<FloatingButton
icon="check"
alwaysVisible
hideOnKeyboard={false}
onPress={async () => {
await db.notes?.addToNotebook(
currentNotebook.id,

View File

@@ -46,7 +46,7 @@ export function toCamelCase(title: string) {
export function openMonographsWebpage() {
try {
openLinkInBrowser(
"https://help.notesnook.com/publish-notes-with-monographs"
"https://notesnook.com/help/publish-notes-with-monographs"
);
} catch (e) {
console.error(e);

View File

@@ -28,6 +28,7 @@ import {
} from "../../components/dialogs/progress";
import Navigation from "../../services/navigation";
import BackupService from "../../services/backup";
import { useUserStore } from "../../stores/use-user-store";
export async function logoutUser() {
const hasUnsyncedChanges = await db.hasUnsyncedChanges();
@@ -47,6 +48,7 @@ export async function logoutUser() {
: undefined,
positivePress: async (_, takeBackup) => {
eSendEvent(eCloseSimpleDialog);
useUserStore.getState().setIsLoggingOut(true);
setTimeout(async () => {
try {
startProgress({
@@ -104,6 +106,7 @@ export async function logoutUser() {
DatabaseLogger.error(e);
ToastManager.error(e as Error, strings.logoutError());
endProgress();
useUserStore.getState().setIsLoggingOut(false);
}
}, 300);
}

View File

@@ -208,15 +208,15 @@ export const settingsGroups: SettingSection[] = [
return status === SubscriptionStatus.TRIAL
? strings.trialOnGoing(trialEndDate)
: status === SubscriptionStatus.ACTIVE
? strings.subRenewOn(expiryDate)
: status === SubscriptionStatus.CANCELED ||
status === SubscriptionStatus.PAUSED
? strings.subEndsOn(expiryDate)
: status === SubscriptionStatus.EXPIRED
? subscriptionDaysLeft.time < -3
? strings.subEnded()
: strings.accountDowngradedIn(3)
: strings.neverHesitate();
? strings.subRenewOn(expiryDate)
: status === SubscriptionStatus.CANCELED ||
status === SubscriptionStatus.PAUSED
? strings.subEndsOn(expiryDate)
: status === SubscriptionStatus.EXPIRED
? subscriptionDaysLeft.time < -3
? strings.subEnded()
: strings.accountDowngradedIn(3)
: strings.neverHesitate();
}
return strings.neverHesitate();
@@ -965,6 +965,14 @@ export const settingsGroups: SettingSection[] = [
property: "checkForUpdates",
icon: "update"
},
{
id: "keep-screen-on",
type: "switch",
name: strings.keepScreenOn(),
description: strings.keepScreenOnDesc(),
property: "keepScreenOn",
icon: "cellphone-screenshot"
},
{
id: "image-compression",
type: "component",
@@ -1671,7 +1679,7 @@ export const settingsGroups: SettingSection[] = [
id: "docs-link",
name: strings.documentation(),
modifer: async () => {
Linking.openURL("https://help.notesnook.com/");
Linking.openURL("https://notesnook.com/help/");
},
description: strings.documentationDesc(),
icon: "file-document"

View File

@@ -472,7 +472,7 @@ function ThemeSelector() {
actionText: strings.learnMore(),
func: () => {
openLinkInBrowser(
"https://help.notesnook.com/custom-themes/introduction"
"https://notesnook.com/help/custom-themes/introduction"
);
}
});

View File

@@ -177,9 +177,11 @@ function resetRootState(
if (state.routes.length < 2) return;
const routes = state.routes.filter(
let routes = state.routes.filter(
(route) =>
(route.name !== "Auth" && route.name !== "Welcome") ||
(route.name !== "Auth" &&
route.name !== "Welcome" &&
route.name !== "AddReminder") ||
route.key === focusedRoute.key
);

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