1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-12-08 23:07:32 +02:00

Compare commits

..

137 Commits

Author SHA1 Message Date
Laurent Cozic
9e8500c148 Server v3.4.3 2025-09-09 09:47:29 +01:00
Laurent Cozic
4f1999f921 Merge branch 'release-3.4' into dev 2025-09-09 09:46:39 +01:00
Laurent Cozic
6ee9571069 iOS 13.4.3 2025-09-09 09:25:40 +01:00
krevad
10663b1494 All: Translation: Update sv.po (#13163) 2025-09-09 04:15:15 -04:00
Laurent Cozic
f25db9bbd7 Android 3.4.7 2025-09-09 09:14:11 +01:00
Laurent Cozic
44ac261304 Desktop release v3.4.11 2025-09-09 09:05:11 +01:00
renovate[bot]
eac995a209 Update dependency esbuild to v0.25.5 (#13040)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2025-09-09 00:32:19 +01:00
Henry Heino
15c973e885 Chore: Mobile: Add additional plugin panel integration tests (#13152) 2025-09-09 00:31:12 +01:00
Henry Heino
1762f9485f Web: Fixes #13153: Fix installing certain plugins (#13154) 2025-09-09 00:30:48 +01:00
Henry Heino
7777f8428f Desktop: Upgrade to Electron 37.4.0 (#13156) 2025-09-09 00:30:06 +01:00
Henry Heino
948aa9db4f Mobile: Upgrade react-native-quick-crypto to v0.7.17 (#13155) 2025-09-09 00:07:29 +01:00
Henry Heino
fdde04ee85 Desktop,Mobile,Cli: Support accepting shares with a new key format (#12829) 2025-09-08 23:56:40 +01:00
Laurent Cozic
f77a20f5d5 Merge branch 'release-3.4' into dev 2025-09-08 23:55:24 +01:00
Henry Heino
d43aa2a3e6 Web: Update the beta notice (#13150) 2025-09-08 23:47:19 +01:00
Henry Heino
04d5ce13c2 Chore: Android: Compile Whisper with support for 16 KB pages (#13118) 2025-09-08 16:50:48 +01:00
Laurent Cozic
3b764ba06a Server: Remove the need to install pm2-logrotate on startup so that image can work in a closed environment (#13149) 2025-09-08 16:37:35 +01:00
Henry Heino
5492ce55fa Server: Fixes #12984: Improve handling of concurrent deletion requests for the same item (#13092) 2025-09-08 12:03:20 +01:00
Henry Heino
f6b3f9860c Cli: Fix last change sometimes lost when not in TUI mode (#13090) 2025-09-08 12:03:13 +01:00
Henry Heino
88f687ba6a Chore: Sync fuzzer: Add actions for publishing and unpublishing notes (#13062) 2025-09-08 12:02:53 +01:00
Henry Heino
1f0a98999f Desktop, Mobile: Fixes #12987: Fix images rendered in the Markdown editor don't reload when downloaded (#13045) 2025-09-08 12:01:54 +01:00
mrjo118
69135c3bea Mobile: Fixes #12956: Resize the notes menu to the viewport when the keyboard is open (#13035) 2025-09-08 12:01:24 +01:00
pedr
c27d542a4b Desktop: Fixes #12049: Fix files without extension not being imported properly (#12974)
Co-authored-by: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
2025-09-08 11:46:36 +01:00
mrjo118
bd1c2534c5 Mobile: Fixes #13095: Fix long note title doesn’t wrap properly for To Do type note (#13099) 2025-09-08 11:05:19 +01:00
mrjo118
72513b520c Android: Fixes #13079: Fix dropdown menus are offset on Android 15+ (#13106) 2025-09-08 11:04:46 +01:00
Henry Heino
ec0f9ef9bc Server: Fix unique constraint error when multiple createSharedFolderUserItems are run concurrently (#13112) 2025-09-08 11:03:28 +01:00
Henry Heino
818bc3218a Mobile: Improve tag dialog performance with long tags and many tags (#13117) 2025-09-08 11:03:01 +01:00
Henry Heino
82760a5b6a Web: Show a "Give feedback" banner and link to a survey (#13125) 2025-09-08 10:59:40 +01:00
mrjo118
5ba9a16cfd Mobile: Fixes #13116: Fix tag association screen no longer searches case insensitively or searches tag endings (#13128) 2025-09-08 10:59:01 +01:00
Henry Heino
68fc91fdc7 Desktop: Resolves #13096: Prefer user-specified CSS page sizing when printing to PDF (#13130) 2025-09-08 10:58:16 +01:00
Henry Heino
bdc4687327 Chore: Refactor WebViewController (#13133) 2025-09-08 10:56:51 +01:00
Henry Heino
3a9f57e13f Cli: Fixes #13086: Fix "use" command when not in TUI mode (#13091) 2025-09-08 10:56:08 +01:00
Henry Heino
b72c48c693 Mobile, Desktop: Fixes #13103: Fix error when saving in-editor rendering-related settings (#13105) 2025-09-08 10:56:01 +01:00
Henry Heino
f1e42f3bac iOS: Fixes #13111: Fix "scan notebook" tool on iOS (#13114) 2025-09-08 10:55:48 +01:00
Henry Heino
93c908286d Mobile: Plugins: Fix renderer plugins that use the settingValue API (#13131) 2025-09-08 10:55:42 +01:00
Henry Heino
4eb8777ed0 Mobile: Fix light bar shown above header in dark mode (#13132) 2025-09-08 10:55:15 +01:00
summoner
5e1909cee0 All: Translation: Update hu_HU.po (#13142) 2025-09-08 00:34:54 -04:00
pplulee
2e7b312415 All: Translation: Update zh_CN.po (#13137) 2025-09-06 17:02:31 -04:00
Joplin Bot
7735a59fc1 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-09-04 18:26:55 +00:00
Laurent Cozic
41d6e912a7 Doc: Updated sponsors 2025-09-04 17:43:49 +02:00
Joplin Bot
4c2fae8423 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-09-03 01:00:02 +00:00
Laurent Cozic
b72c134890 Doc: Update sponsors 2025-09-02 23:01:06 +02:00
Joplin Bot
58a9c229bb Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-09-01 18:25:20 +00:00
Laurent Cozic
d8c203bb8a Merge branch 'release-3.4' into dev 2025-09-01 14:48:55 +02:00
Laurent Cozic
9020c07825 lock files 2025-09-01 14:48:51 +02:00
Laurent Cozic
e884da8312 Android 3.4.6 2025-09-01 14:48:38 +02:00
Joplin Bot
d134ea8bfe Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-09-01 12:33:37 +00:00
Henry Heino
faa44468f3 Mobile: Plugins: Improve handling of invalid toolbar button enabled conditions (#13076) 2025-09-01 13:50:59 +02:00
Laurent Cozic
85585d16d2 Desktop release v3.4.10 2025-09-01 13:50:43 +02:00
Joplin Bot
b9c5b8f187 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-09-01 01:12:51 +00:00
Henry Heino
da8e638359 Chore: Mobile: Add test to verify that content scripts load in the note viewer (#13093) 2025-08-31 00:32:10 +02:00
Henry Heino
6482ab5a4e Mobile: Plugin API: Fix compatibility with certain plugins targetting the desktop app (#13077) 2025-08-29 23:28:45 +02:00
Henry Heino
ec74abe754 Mobile: Plugin API: Fix certain renderer plugins fail to load (#13078) 2025-08-29 23:28:39 +02:00
Henry Heino
859bc8d88e Mobile: Plugins: Fix plugin panel buttons are offscreen on recent versions of Android (#13080) 2025-08-29 23:28:22 +02:00
Henry Heino
56ed471a2f Chore: Rich Text Editor: Refactor editor dialog to simplify toggling the dialog from external commands (#13082) 2025-08-29 23:28:11 +02:00
Henry Heino
650594ecea Chore: Sync fuzzer: Add action for deleting notes (#13083) 2025-08-29 23:28:00 +02:00
Henry Heino
3e9bb914e5 Android: Fixes #13015: Fix "edit profile" button is partially offscreen (#13084) 2025-08-29 23:27:51 +02:00
Henry Heino
f75e911a4e Docs: Update the privacy policy (#13087) 2025-08-29 23:27:44 +02:00
Eric Duarte
78fb07d4c7 All: Translation: Update ca.po (#13065) 2025-08-28 17:50:34 -04:00
Henry Heino
6390ef43ed Desktop: Clarify handwritten text transcription setting (#13073)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-28 09:20:55 +03:00
Henry Heino
78c5c4d7c3 Android: Accessibility: Fix tag search input loses focus when submitted by pressing "enter" (#13070) 2025-08-28 09:20:10 +03:00
Henry Heino
0d1d50768b Android: Fix shadow shown above the screen header (#13074) 2025-08-28 09:04:10 +03:00
Henry Heino
57093b35ea Android: Fixes #12960: Rich Text Editor: Fix pressing enter does nothing in some cases (#13075) 2025-08-28 09:03:37 +03:00
Laurent Cozic
cba5cf660b Desktop release v3.4.9 2025-08-27 22:09:47 +03:00
Laurent Cozic
0024722c79 Desktop: Clarified that handwritten transcription may not always work 2025-08-27 22:09:26 +03:00
Henry Heino
bc2832e78f Chore: Desktop: Allow access to more Joplin APIs from the desktop development tools in dev mode (#13052) 2025-08-27 22:05:52 +03:00
Henry Heino
424cc96d36 Chore: Sync fuzzer: Fix incorrect expected state after removing the last user from a share (#13061) 2025-08-27 22:03:17 +03:00
Henry Heino
56fd5d828f Android: Fixes #12952: External keyboard: Fix adding tags by pressing enter on certain Android devices (#13069) 2025-08-27 22:02:48 +03:00
Henry Heino
03843b087a Desktop: Fixes #12816: Accessibility: Fix dismissing the alarm dialog by pressing escape (#13068) 2025-08-27 22:02:34 +03:00
Henry Heino
b179509dd3 Desktop: Fixes #12855: Legacy editor: Fix plugin support (#13066) 2025-08-27 22:02:09 +03:00
Joplin Bot
f6851314d2 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-27 18:26:06 +00:00
Laurent Cozic
eaec45cb3f Doc: Update sponsors 2025-08-27 18:38:56 +03:00
Laurent Cozic
9be954496c Doc: Update sponsors 2025-08-27 17:45:40 +03:00
Laurent Cozic
ac289c5198 Desktop: Clarified that handwritten transcription may not always work 2025-08-27 17:22:06 +03:00
Joplin Bot
98ef5e619b Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-27 12:33:13 +00:00
Laurent Cozic
62faa48aac lock files 2025-08-27 10:15:15 +03:00
Laurent Cozic
5daa7a1f4c Chore: By default, create new releases as pre-releases when publishing desktop app 2025-08-27 09:54:06 +03:00
Laurent Cozic
32be071601 CLI v3.4.1 2025-08-27 09:50:10 +03:00
Laurent Cozic
0dc63dd306 Lock file 2025-08-27 09:47:17 +03:00
Laurent Cozic
78ed58187a Releasing sub-packages 2025-08-27 09:46:45 +03:00
Laurent Cozic
b8b8dd8011 iOS 13.4.2 2025-08-27 09:33:18 +03:00
Laurent Cozic
0bc72b45be Android 3.4.5 2025-08-27 09:28:51 +03:00
Laurent Cozic
c52523134d Desktop release v3.4.8 2025-08-27 09:23:04 +03:00
Henry Heino
aff871eee6 Desktop, Mobile: Markdown editor: Fix image rendering is disabled unless markup rendering is also enabled (#13056) 2025-08-27 09:21:26 +03:00
Henry Heino
a5a68a2238 Cli: Add commands for publishing and unpublishing notes with Joplin Server (#13060) 2025-08-27 09:21:10 +03:00
Henry Heino
e066b8f9bc Desktop: Fixes #13043: OCR: Fix processing resources with an invalid ocr_driver_id (#13051) 2025-08-27 09:20:00 +03:00
Henry Heino
e7827a3a64 Mobile: Remove the "beta" warning from the plugin settings screen (#13063) 2025-08-27 09:19:21 +03:00
Henry Heino
4ceca647dc Desktop, Mobile: Resolves #13048: Auto-disable plugin settings when conflicting built-in settings are enabled (#13055) 2025-08-27 09:19:07 +03:00
Henry Heino
4185afebdb Chore: Fix build (#13050) 2025-08-26 18:40:26 +03:00
Henry Heino
c530b07f45 Desktop, Mobile: Disable in-editor Markdown rendering by default (can be re-enabled in settings > note) (#13022) 2025-08-26 10:56:53 +03:00
Henry Heino
0ed7daaed8 Linux, Windows: Fixes #12991, #12733: Fix notifications (#13007) 2025-08-26 10:56:42 +03:00
Henry Heino
2eb107c716 Desktop, Mobile: Add a "highlight active line" setting (#12967) 2025-08-26 10:49:59 +03:00
Henry Heino
c99780db1b Mobile: Rich Text Editor: Avoid rendering links with unknown protocols (#12943)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-26 10:49:26 +03:00
Henry Heino
ac05b7d389 Mobile: Rich Text Editor: Fix additional blank lines added around list items on save (#12935) 2025-08-26 10:46:00 +03:00
Henry Heino
9719d82c47 Desktop: Fixes #13024: OCR: Fix infinite loop (#13025) 2025-08-26 10:45:27 +03:00
Henry Heino
48694a585f Mobile: Fixes #12953: Allow the tag dialog to scroll when little screen space is available (#13028) 2025-08-26 10:44:32 +03:00
Henry Heino
b577a27887 Mobile: Fixes #13027: Fix additional space added around app content in landscape mode (#13030) 2025-08-26 10:44:06 +03:00
Helmut K. C. Tessarek
9f649c9fc2 All: Update translations 2025-08-25 19:40:33 -04:00
Eric Duarte
8c9c5d13bd All: Translation: Update es_ES.po (#13041) 2025-08-25 19:32:14 -04:00
renovate[bot]
96692de93c Update dependency @types/react to v18.3.23 (#13042)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 13:44:38 +00:00
Joplin Bot
3d8e1dd146 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-24 18:26:08 +00:00
Joplin Bot
227e41b69a Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-24 12:31:05 +00:00
renovate[bot]
a616e26a0f Update dependency react-native-safe-area-context to v5.4.1 (#13000)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-24 13:02:03 +03:00
Joplin Bot
ba0e7e2226 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-23 12:30:39 +00:00
Laurent Cozic
b5a4ba554d Doc: Add sponsor 2025-08-23 13:14:58 +03:00
Arda Kılıçdağı
9037da8f2d All: Translation: Update tr_TR.po (#13019) 2025-08-22 16:07:31 -04:00
renovate[bot]
6998606ec9 Update dependency pg to v8.15.6 (#13021)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-22 17:00:30 +00:00
Laurent Cozic
66d52c90a3 Desktop release v3.4.7 2025-08-22 13:19:27 +03:00
renovate[bot]
f6fb1f7fbf Update dependency pg to v8.15.5 (#13001)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-22 13:14:06 +03:00
Henry Heino
3aac6043da Chore: Sync fuzzer: Support testing Joplin Cloud readonly shares (#13003) 2025-08-22 11:33:54 +03:00
Henry Heino
ae170e0aa0 Desktop: Fixes #12998: Fix error logged when rendering a non-existent resource (#13004) 2025-08-22 11:33:16 +03:00
Henry Heino
371f027a24 MacOS: Fix startup failure when unable to access the keychain (#13006) 2025-08-22 11:32:59 +03:00
Henry Heino
37422f316e Desktop: Downgrade to Electron 35.7.5 (#13013) 2025-08-22 11:30:39 +03:00
Henry Heino
a9f284ae45 Desktop: Fixes #13009: Fix custom root CA support (#13018) 2025-08-22 11:29:54 +03:00
Milo Ivir
fd2f69cc73 All: Translation: Update hr_HR.po (#13011) 2025-08-21 18:45:39 -04:00
Joplin Bot
c4eab3c79c Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-21 01:03:33 +00:00
renovate[bot]
a0b9c6376e Update dependency react-native-image-picker to v8 (#12997)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 23:37:39 +03:00
Henry Heino
e2fc056369 Desktop,Mobile,Cli: Fixes #12648: Fix unshare action requires two syncs to be reflected locally (#12999) 2025-08-20 23:36:47 +03:00
renovate[bot]
453b4705b1 Update dependency @types/node to v18.19.103 (#12985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 19:25:54 +00:00
Laurent Cozic
4128061e40 Desktop release v3.4.6 2025-08-20 22:22:42 +03:00
Joplin Bot
432b0ca870 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-20 12:32:59 +00:00
renovate[bot]
c484cd2e48 Update dependency sass to v1.87.0 (#12995)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 15:06:57 +03:00
Laurent Cozic
58f0725c6b Doc: Add sponsor 2025-08-20 15:05:28 +03:00
Henry Heino
bf8fbec0cd Chore: Sync fuzzer: Add support for adding and removing share participants (#12988) 2025-08-20 09:46:23 +03:00
pedr
f1d452f130 Server: Fixes #12983: Not handling correctly non JSON error responses from Transcribe (#12986) 2025-08-20 09:46:15 +03:00
Henry Heino
26012cd7d5 Cli,Mobile,Desktop: Shared folders: Fix moving shared subfolder to toplevel briefly marks it as a toplevel share (#12964) 2025-08-20 09:39:39 +03:00
mrjo118
a414241541 Mobile: Improve tag screen usability to allow add or remove tag with a single press, when the keyboard is open (#12954) 2025-08-20 09:33:31 +03:00
Henry Heino
0f13bf9d51 Mobile: Rich Text Editor: Support rendering subscript, superscript, and highlighted formatting (#12944) 2025-08-20 09:33:13 +03:00
Henry Heino
c142c5c5c0 Desktop,Mobile: Markdown editor: Toggle checkboxes on ctrl-click (#12927) 2025-08-20 09:32:16 +03:00
Henry Heino
af5c0135dc Mobile: Rich Text Editor: Enable syntax highlighting and auto-indent in the code block editor (#12909) 2025-08-20 09:29:30 +03:00
pedr
8a811b9e78 Doc: Resolves #12861: Add end point documentation for Transcribe (#12870)
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-20 09:29:12 +03:00
Henry Heino
602484f143 Desktop: Upgrade to Electron v37.3.0 (#12951) 2025-08-20 08:53:50 +03:00
renovate[bot]
dc84db1657 Update dependency sharp to v0.34.2 (#12982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
2025-08-20 08:33:29 +03:00
Henry Heino
f5882ecfcc Chore: Improve type safety (#12992) 2025-08-20 08:33:10 +03:00
Laurent Cozic
30000c34ec Cli: If no notebook is provided when importing a file, use the default one 2025-08-19 23:33:52 +03:00
renovate[bot]
6e3df1bd90 Update dependency @types/react to v18.3.22 (#12990)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 18:59:17 +03:00
Joplin Bot
67196ac0b2 Doc: Auto-update documentation
Auto-updated using release-website.sh
2025-08-19 12:33:44 +00:00
Laurent Cozic
69646b5522 Doc: Update sponsors 2025-08-19 12:31:13 +03:00
288 changed files with 14817 additions and 6921 deletions

View File

@@ -117,6 +117,8 @@ packages/app-cli/app/command-ls.js
packages/app-cli/app/command-mkbook.test.js
packages/app-cli/app/command-mkbook.js
packages/app-cli/app/command-mv.js
packages/app-cli/app/command-publish.test.js
packages/app-cli/app/command-publish.js
packages/app-cli/app/command-ren.js
packages/app-cli/app/command-restore.js
packages/app-cli/app/command-rmbook.test.js
@@ -129,6 +131,8 @@ packages/app-cli/app/command-share.test.js
packages/app-cli/app/command-share.js
packages/app-cli/app/command-sync.js
packages/app-cli/app/command-testing.js
packages/app-cli/app/command-unpublish.test.js
packages/app-cli/app/command-unpublish.js
packages/app-cli/app/command-use.js
packages/app-cli/app/command-version.js
packages/app-cli/app/gui/FolderListWidget.js
@@ -672,6 +676,8 @@ packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
packages/app-mobile/components/FeedbackBanner.test.js
packages/app-mobile/components/FeedbackBanner.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js
@@ -698,7 +704,6 @@ packages/app-mobile/components/NoteEditor/RichTextEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/WarningBanner.js
packages/app-mobile/components/NoteEditor/commandDeclarations.js
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
@@ -810,7 +815,6 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButto
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.js
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.js
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/pluginServiceSetup.js
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/openWebsiteForPlugin.js
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.js
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.js
@@ -868,6 +872,7 @@ packages/app-mobile/contentScripts/imageEditorBundle/utils/useEditorMessenger.js
packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js
packages/app-mobile/contentScripts/markdownEditorBundle/types.js
packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/markdownEditorBundle/utils/useCodeMirrorPlugins.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
@@ -897,6 +902,7 @@ packages/app-mobile/services/AlarmServiceDriver.web.js
packages/app-mobile/services/BackButtonService.js
packages/app-mobile/services/commands/stateToWhenClauseContext.js
packages/app-mobile/services/e2ee/RSA.react-native.js
packages/app-mobile/services/e2ee/RSA.react-native.web.js
packages/app-mobile/services/e2ee/crypto.js
packages/app-mobile/services/plugins/PlatformImplementation.js
packages/app-mobile/services/profiles/index.js
@@ -918,6 +924,7 @@ packages/app-mobile/utils/ShareUtils.test.js
packages/app-mobile/utils/ShareUtils.js
packages/app-mobile/utils/TlsUtils.js
packages/app-mobile/utils/appDefaultState.js
packages/app-mobile/utils/appReducer.js
packages/app-mobile/utils/autodetectTheme.js
packages/app-mobile/utils/buildStartupTasks.js
packages/app-mobile/utils/checkPermissions.js
@@ -939,6 +946,7 @@ packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
packages/app-mobile/utils/getPackageInfo.js
packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/hooks/useBackHandler.js
packages/app-mobile/utils/hooks/useIsScreenReaderEnabled.js
packages/app-mobile/utils/hooks/useKeyboardState.js
packages/app-mobile/utils/hooks/useOnLongPressProps.js
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
@@ -956,6 +964,7 @@ packages/app-mobile/utils/pickDocument.js
packages/app-mobile/utils/polyfills/bufferPolyfill.js
packages/app-mobile/utils/polyfills/crypto-polyfill/index.js
packages/app-mobile/utils/polyfills/index.js
packages/app-mobile/utils/polyfills/index.web.js
packages/app-mobile/utils/setupNotifications.js
packages/app-mobile/utils/shareFile.js
packages/app-mobile/utils/shareHandler.js
@@ -966,6 +975,7 @@ packages/app-mobile/utils/shim-init-react/shimInitShared.js
packages/app-mobile/utils/testing/createMockReduxStore.js
packages/app-mobile/utils/testing/getWebViewDomById.js
packages/app-mobile/utils/testing/getWebViewWindowById.js
packages/app-mobile/utils/testing/mockPluginServiceSetup.js
packages/app-mobile/utils/testing/setupGlobalStore.js
packages/app-mobile/utils/testing/testingLibrary.js
packages/app-mobile/utils/types.js
@@ -1004,6 +1014,9 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
packages/editor/CodeMirror/extensions/highlightActiveLineExtension.js
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
@@ -1074,9 +1087,11 @@ packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/defaultLanguage.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/lookUpLanguage.js
packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.js
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/ProseMirror/commands.test.js
packages/editor/ProseMirror/commands.js
@@ -1113,6 +1128,8 @@ packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/forEachHeading.js
packages/editor/ProseMirror/utils/jumpToHash.js
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
packages/editor/ProseMirror/utils/postprocessEditorOutput.test.js
packages/editor/ProseMirror/utils/postprocessEditorOutput.js
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
packages/editor/ProseMirror/utils/preprocessEditorInput.js
packages/editor/ProseMirror/utils/sanitizeHtml.js
@@ -1392,14 +1409,19 @@ packages/lib/services/database/types.js
packages/lib/services/debug/populateDatabase.js
packages/lib/services/e2ee/EncryptionService.test.js
packages/lib/services/e2ee/EncryptionService.js
packages/lib/services/e2ee/RSA.node.js
packages/lib/services/e2ee/crypto.test.js
packages/lib/services/e2ee/crypto.js
packages/lib/services/e2ee/cryptoShared.js
packages/lib/services/e2ee/cryptoTestUtils.js
packages/lib/services/e2ee/ppk.test.js
packages/lib/services/e2ee/ppk.js
packages/lib/services/e2ee/ppkTestUtils.js
packages/lib/services/e2ee/ppk/RSA.node.js
packages/lib/services/e2ee/ppk/ppk.test.js
packages/lib/services/e2ee/ppk/ppk.js
packages/lib/services/e2ee/ppk/ppkTestUtils.js
packages/lib/services/e2ee/ppk/webCrypto/LongDataWrapper.js
packages/lib/services/e2ee/ppk/webCrypto/StringToBufferWrapper.js
packages/lib/services/e2ee/ppk/webCrypto/WebCryptoRsa.js
packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.test.js
packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.js
packages/lib/services/e2ee/types.js
packages/lib/services/e2ee/utils.test.js
packages/lib/services/e2ee/utils.js
@@ -1770,6 +1792,7 @@ packages/tools/fuzzer/Client.js
packages/tools/fuzzer/ClientPool.js
packages/tools/fuzzer/Server.js
packages/tools/fuzzer/constants.js
packages/tools/fuzzer/model/FolderRecord.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/SeededRandom.js

View File

@@ -40,4 +40,29 @@ jobs:
cd packages/app-mobile/android
sed -i -- 's/signingConfig signingConfigs.release/signingConfig signingConfigs.debug/' app/build.gradle
./gradlew assembleRelease
- name: Verify alignment
run: |
cd packages/app-mobile/android/app
APK_FILE="./build/outputs/apk/release/app-release.apk"
if test ! -f "$APK_FILE" ; then
echo "APK file not found."
exit 1
else
echo "APK file found at: $APK_FILE"
fi
BUILD_TOOLS_PATH="$ANDROID_HOME/build-tools/"
if test ! -d "$BUILD_TOOLS_PATH" ; then
echo "Build tools not found at $BUILD_TOOLS_PATH ($ANDROID_HOME, $BUILD_TOOLS_VERSION)"
exit 1
fi
# The build-tools/ directory contains different subdirectories
# for each build tools version. As a result, there may be multiple
# zipalign tools. Select one of them:
ZIPALIGN_PATH="$(find $BUILD_TOOLS_PATH -name "zipalign" -print | head -n1)"
if test ! -x "$ZIPALIGN_PATH" ; then
echo "zipalign not found (searching in $BUILD_TOOLS_PATH, candidate: $ZIPALIGN_PATH)"
exit 1
fi
"$ZIPALIGN_PATH" -c -P 16 -v 4 "$APK_FILE"

35
.gitignore vendored
View File

@@ -90,6 +90,8 @@ packages/app-cli/app/command-ls.js
packages/app-cli/app/command-mkbook.test.js
packages/app-cli/app/command-mkbook.js
packages/app-cli/app/command-mv.js
packages/app-cli/app/command-publish.test.js
packages/app-cli/app/command-publish.js
packages/app-cli/app/command-ren.js
packages/app-cli/app/command-restore.js
packages/app-cli/app/command-rmbook.test.js
@@ -102,6 +104,8 @@ packages/app-cli/app/command-share.test.js
packages/app-cli/app/command-share.js
packages/app-cli/app/command-sync.js
packages/app-cli/app/command-testing.js
packages/app-cli/app/command-unpublish.test.js
packages/app-cli/app/command-unpublish.js
packages/app-cli/app/command-use.js
packages/app-cli/app/command-version.js
packages/app-cli/app/gui/FolderListWidget.js
@@ -645,6 +649,8 @@ packages/app-mobile/components/ExtendedWebView/index.js
packages/app-mobile/components/ExtendedWebView/index.web.js
packages/app-mobile/components/ExtendedWebView/types.js
packages/app-mobile/components/ExtendedWebView/utils/useCss.js
packages/app-mobile/components/FeedbackBanner.test.js
packages/app-mobile/components/FeedbackBanner.js
packages/app-mobile/components/FolderPicker.js
packages/app-mobile/components/Icon.js
packages/app-mobile/components/IconButton.js
@@ -671,7 +677,6 @@ packages/app-mobile/components/NoteEditor/RichTextEditor.js
packages/app-mobile/components/NoteEditor/SearchPanel.js
packages/app-mobile/components/NoteEditor/WarningBanner.js
packages/app-mobile/components/NoteEditor/commandDeclarations.js
packages/app-mobile/components/NoteEditor/hooks/useCodeMirrorPlugins.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.test.js
packages/app-mobile/components/NoteEditor/hooks/useEditorCommandHandler.js
packages/app-mobile/components/NoteEditor/testing/createTestEditorProps.js
@@ -783,7 +788,6 @@ packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButto
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.js
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.js
packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/pluginServiceSetup.js
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/openWebsiteForPlugin.js
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.js
packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.js
@@ -841,6 +845,7 @@ packages/app-mobile/contentScripts/imageEditorBundle/utils/useEditorMessenger.js
packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.js
packages/app-mobile/contentScripts/markdownEditorBundle/types.js
packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.js
packages/app-mobile/contentScripts/markdownEditorBundle/utils/useCodeMirrorPlugins.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.js
packages/app-mobile/contentScripts/rendererBundle/contentScript/index.js
@@ -870,6 +875,7 @@ packages/app-mobile/services/AlarmServiceDriver.web.js
packages/app-mobile/services/BackButtonService.js
packages/app-mobile/services/commands/stateToWhenClauseContext.js
packages/app-mobile/services/e2ee/RSA.react-native.js
packages/app-mobile/services/e2ee/RSA.react-native.web.js
packages/app-mobile/services/e2ee/crypto.js
packages/app-mobile/services/plugins/PlatformImplementation.js
packages/app-mobile/services/profiles/index.js
@@ -891,6 +897,7 @@ packages/app-mobile/utils/ShareUtils.test.js
packages/app-mobile/utils/ShareUtils.js
packages/app-mobile/utils/TlsUtils.js
packages/app-mobile/utils/appDefaultState.js
packages/app-mobile/utils/appReducer.js
packages/app-mobile/utils/autodetectTheme.js
packages/app-mobile/utils/buildStartupTasks.js
packages/app-mobile/utils/checkPermissions.js
@@ -912,6 +919,7 @@ packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js
packages/app-mobile/utils/getPackageInfo.js
packages/app-mobile/utils/getVersionInfoText.js
packages/app-mobile/utils/hooks/useBackHandler.js
packages/app-mobile/utils/hooks/useIsScreenReaderEnabled.js
packages/app-mobile/utils/hooks/useKeyboardState.js
packages/app-mobile/utils/hooks/useOnLongPressProps.js
packages/app-mobile/utils/hooks/useReduceMotionEnabled.js
@@ -929,6 +937,7 @@ packages/app-mobile/utils/pickDocument.js
packages/app-mobile/utils/polyfills/bufferPolyfill.js
packages/app-mobile/utils/polyfills/crypto-polyfill/index.js
packages/app-mobile/utils/polyfills/index.js
packages/app-mobile/utils/polyfills/index.web.js
packages/app-mobile/utils/setupNotifications.js
packages/app-mobile/utils/shareFile.js
packages/app-mobile/utils/shareHandler.js
@@ -939,6 +948,7 @@ packages/app-mobile/utils/shim-init-react/shimInitShared.js
packages/app-mobile/utils/testing/createMockReduxStore.js
packages/app-mobile/utils/testing/getWebViewDomById.js
packages/app-mobile/utils/testing/getWebViewWindowById.js
packages/app-mobile/utils/testing/mockPluginServiceSetup.js
packages/app-mobile/utils/testing/setupGlobalStore.js
packages/app-mobile/utils/testing/testingLibrary.js
packages/app-mobile/utils/types.js
@@ -977,6 +987,9 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.test.js
packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
packages/editor/CodeMirror/editorCommands/supportsCommand.js
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
packages/editor/CodeMirror/extensions/ctrlClickActionExtension.js
packages/editor/CodeMirror/extensions/ctrlClickCheckboxExtension.js
packages/editor/CodeMirror/extensions/highlightActiveLineExtension.js
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
@@ -1047,9 +1060,11 @@ packages/editor/CodeMirror/utils/isInSyntaxNode.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/allLanguages.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/defaultLanguage.js
packages/editor/CodeMirror/utils/markdown/codeBlockLanguages/lookUpLanguage.js
packages/editor/CodeMirror/utils/markdown/getCheckboxAtPosition.js
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.test.js
packages/editor/CodeMirror/utils/markdown/renumberSelectedLists.js
packages/editor/CodeMirror/utils/markdown/stripBlockquote.js
packages/editor/CodeMirror/utils/markdown/toggleCheckboxAt.js
packages/editor/CodeMirror/utils/setupVim.js
packages/editor/ProseMirror/commands.test.js
packages/editor/ProseMirror/commands.js
@@ -1086,6 +1101,8 @@ packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/forEachHeading.js
packages/editor/ProseMirror/utils/jumpToHash.js
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
packages/editor/ProseMirror/utils/postprocessEditorOutput.test.js
packages/editor/ProseMirror/utils/postprocessEditorOutput.js
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
packages/editor/ProseMirror/utils/preprocessEditorInput.js
packages/editor/ProseMirror/utils/sanitizeHtml.js
@@ -1365,14 +1382,19 @@ packages/lib/services/database/types.js
packages/lib/services/debug/populateDatabase.js
packages/lib/services/e2ee/EncryptionService.test.js
packages/lib/services/e2ee/EncryptionService.js
packages/lib/services/e2ee/RSA.node.js
packages/lib/services/e2ee/crypto.test.js
packages/lib/services/e2ee/crypto.js
packages/lib/services/e2ee/cryptoShared.js
packages/lib/services/e2ee/cryptoTestUtils.js
packages/lib/services/e2ee/ppk.test.js
packages/lib/services/e2ee/ppk.js
packages/lib/services/e2ee/ppkTestUtils.js
packages/lib/services/e2ee/ppk/RSA.node.js
packages/lib/services/e2ee/ppk/ppk.test.js
packages/lib/services/e2ee/ppk/ppk.js
packages/lib/services/e2ee/ppk/ppkTestUtils.js
packages/lib/services/e2ee/ppk/webCrypto/LongDataWrapper.js
packages/lib/services/e2ee/ppk/webCrypto/StringToBufferWrapper.js
packages/lib/services/e2ee/ppk/webCrypto/WebCryptoRsa.js
packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.test.js
packages/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider.js
packages/lib/services/e2ee/types.js
packages/lib/services/e2ee/utils.test.js
packages/lib/services/e2ee/utils.js
@@ -1743,6 +1765,7 @@ packages/tools/fuzzer/Client.js
packages/tools/fuzzer/ClientPool.js
packages/tools/fuzzer/Server.js
packages/tools/fuzzer/constants.js
packages/tools/fuzzer/model/FolderRecord.js
packages/tools/fuzzer/sync-fuzzer.js
packages/tools/fuzzer/types.js
packages/tools/fuzzer/utils/SeededRandom.js

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -63,11 +63,22 @@ FROM node:18-slim
ARG user=joplin
RUN useradd --create-home --shell /bin/bash $user
# Install PM2 and set home directory. Setting the PM2 data dir so modules/config persist regardless
# of user home.
RUN npm i -g pm2@5.4.3 && mkdir -p /opt/pm2 && chown -R $user:$user /opt/pm2
ENV PM2_HOME=/opt/pm2
USER $user
COPY --chown=$user:$user --from=builder /build/packages /home/$user/packages
COPY --chown=$user:$user --from=builder /usr/bin/tini /usr/local/bin/tini
# Install pm2-logrotate and default settings as the runtime user
RUN pm2 install pm2-logrotate \
&& pm2 set pm2-logrotate:max_size 100MB \
&& pm2 set pm2-logrotate:retain 5 \
&& pm2 set pm2-logrotate:compress true
ENV NODE_ENV=production
ENV RUNNING_IN_DOCKER=1
EXPOSE ${APP_PORT}

View File

@@ -31,7 +31,7 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
# Sponsors
<!-- SPONSORS-ORG -->
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://www.hosting.de/nextcloud/?mtm_campaign=managed-nextcloud&amp;mtm_kwd=joplinapp&amp;mtm_source=joplinapp-webseite&amp;mtm_medium=banner"><img title="Hosting.de" width="256" src="https://joplinapp.org/images/sponsors/HostingDe.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://realgambling.ca/"><img title="RealGambling.ca" width="256" src="https://joplinapp.org/images/sponsors/RealGambling.png" alt="RealGambling.ca"/></a> <a href="https://essaypro.com/"><img title="write an essay online with EssayPro" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="write an essay online with EssayPro"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://essaywriter.pro"><img title="write my essay services by EssayWriter" width="256" src="https://joplinapp.org/images/sponsors/EssayWriterPro.png" alt="write my essay services by EssayWriter"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://homeworkguy.org/someone-to-take-my-online-class"><img title="someone to take my online class" width="256" src="https://joplinapp.org/images/sponsors/HomeworkGuy.png" alt="someone to take my online class"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a>
<a href="https://seirei.ne.jp"><img title="Serei Network" width="256" src="https://joplinapp.org/images/sponsors/SeireiNetwork.png"/></a> <a href="https://citricsheep.com"><img title="Citric Sheep" width="256" src="https://joplinapp.org/images/sponsors/CitricSheep.png"/></a> <a href="https://sorted.travel/?utm_source=joplinapp"><img title="Sorted Travel" width="256" src="https://joplinapp.org/images/sponsors/SortedTravel.png"/></a> <a href="https://celebian.com"><img title="Celebian" width="256" src="https://joplinapp.org/images/sponsors/Celebian.png"/></a> <a href="https://bestkru.com"><img title="BestKru" width="256" src="https://joplinapp.org/images/sponsors/BestKru.png"/></a> <a href="https://www.socialfollowers.uk/buy-tiktok-followers/"><img title="Social Followers" width="256" src="https://joplinapp.org/images/sponsors/SocialFollowers.png"/></a> <a href="https://stormlikes.com/"><img title="Stormlikes" width="256" src="https://joplinapp.org/images/sponsors/Stormlikes.png"/></a> <a href="https://route4me.com"><img title="Route4Me" width="256" src="https://joplinapp.org/images/sponsors/Route4Me.png"/></a> <a href="https://topagency.webflow.io"><img title="WebDesignAgency" width="256" src="https://joplinapp.org/images/sponsors/WebDesignAgency.png" alt="topagency"/></a> <a href="https://www.slotozilla.com/nz/no-deposit-bonus"><img title="casino without making any upfront cost" width="256" src="https://joplinapp.org/images/sponsors/Slotozilla.png" alt="casino without making any upfront cost"/></a> <a href="https://essayservice.com"><img title="quick and reliable service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/EssayService.png" alt="quick and reliable service to write my paper for me"/></a> <a href="https://writepaper.com/"><img title="best service to write my paper for me" width="256" src="https://joplinapp.org/images/sponsors/WritePaper.png" alt="best service to write my paper for me"/></a> <a href="https://paperwriter.com/"><img title="high-quality paper writing service PaperWriter" width="256" src="https://joplinapp.org/images/sponsors/PaperWriter.png" alt="high-quality paper writing service PaperWriter"/></a> <a href="https://www.bestetf.net/"><img title="BestETF" width="256" src="https://joplinapp.org/images/sponsors/BestEtf.png" alt="BestETF"/></a> <a href="https://freespinny.io/free-spins-no-deposit/"><img title="Freespinny.io Free Spins Bonus site" width="256" src="https://joplinapp.org/images/sponsors/Freespinny.png" alt="Freespinny.io Free Spins Bonus site"/></a> <a href="https://damangameplay.in"><img title="Daman Game" width="256" src="https://joplinapp.org/images/sponsors/DamanGame.png" alt="Daman Game"/></a> <a href="https://essayshark.com"><img title="EssayShark - essay writers for hire" width="256" src="https://joplinapp.org/images/sponsors/EssayShark.png" alt="EssayShark - essay writers for hire"/></a> <a href="https://pokieslab1.com/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/PokiesLab.png" alt="Australian Real Money Pokies"/></a> <a href="https://pokiesman1.net/real-money-pokies/"><img title="Australian Real Money Pokies" width="256" src="https://joplinapp.org/images/sponsors/Pokiesman.png" alt="Australian Real Money Pokies"/></a> <a href="https://domyessay.com"><img title="Essay writers DoMyEssay are dedicated to providing top-notch, custom-written papers that meet your academic requirements" width="256" src="https://joplinapp.org/images/sponsors/DoMyEssay.png" alt="DoMyEssay"/></a> <a href="https://essaypro.com/"><img title="best essay writing service" width="256" src="https://joplinapp.org/images/sponsors/EssayPro.png" alt="best essay writing service"/></a> <a href="https://socialkings.online"><img title="Boost your reach and buy real followers" width="256" src="https://joplinapp.org/images/sponsors/SocialKings.png" alt="Boost your reach and buy real followers"/></a>
<!-- SPONSORS-ORG -->
* * *
@@ -40,9 +40,8 @@ Please see the [donation page](https://github.com/laurent22/joplin/blob/dev/read
| | | | |
| :---: | :---: | :---: | :---: |
| <img width="50" src="https://avatars2.githubusercontent.com/u/97193607?s=96&v=4"/></br>[Akhil-CM](https://github.com/Akhil-CM) | <img width="50" src="https://avatars2.githubusercontent.com/u/552452?s=96&v=4"/></br>[andypiper](https://github.com/andypiper) | <img width="50" src="https://avatars2.githubusercontent.com/u/215668?s=96&v=4"/></br>[avanderberg](https://github.com/avanderberg) | <img width="50" src="https://avatars2.githubusercontent.com/u/67130?s=96&v=4"/></br>[chr15m](https://github.com/chr15m) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) | <img width="50" src="https://avatars2.githubusercontent.com/u/8030470?s=96&v=4"/></br>[Galliver7](https://github.com/Galliver7) | <img width="50" src="https://avatars2.githubusercontent.com/u/64712218?s=96&v=4"/></br>[Hegghammer](https://github.com/Hegghammer) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/1788010?s=96&v=4"/></br>[maxtruxa](https://github.com/maxtruxa) | <img width="50" src="https://avatars2.githubusercontent.com/u/327998?s=96&v=4"/></br>[sif](https://github.com/sif) | <img width="50" src="https://avatars2.githubusercontent.com/u/765564?s=96&v=4"/></br>[taskcruncher](https://github.com/taskcruncher) |
| <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) | | | |
| <img width="50" src="https://avatars2.githubusercontent.com/u/1177810?s=96&v=4"/></br>[felixstorm](https://github.com/felixstorm) | <img width="50" src="https://avatars2.githubusercontent.com/u/11947658?s=96&v=4"/></br>[KentBrockman](https://github.com/KentBrockman) | <img width="50" src="https://avatars2.githubusercontent.com/u/42319182?s=96&v=4"/></br>[marcdw1289](https://github.com/marcdw1289) | <img width="50" src="https://avatars2.githubusercontent.com/u/668977?s=96&v=4"/></br>[ugoertz](https://github.com/ugoertz) |
| | | | |
<!-- SPONSORS-GITHUB -->
# Community

View File

@@ -417,8 +417,10 @@ class Application extends BaseApplication {
if (argv.length) {
this.gui_ = this.dummyGui();
const initialFolder = await Folder.load(Setting.value('activeFolderId'));
await this.switchCurrentFolder(initialFolder);
await this.applySettingsSideEffects();
await this.refreshCurrentFolder();
try {
await this.execCommand(argv);
} catch (error) {
@@ -432,6 +434,7 @@ class Application extends BaseApplication {
}
await Setting.saveAll();
await this.database_.close();
// Need to call exit() explicitly, otherwise Node wait for any timeout to complete
// https://stackoverflow.com/questions/18050095

View File

@@ -6,6 +6,7 @@ import app from './app';
import { _ } from '@joplin/lib/locale';
import { ImportOptions } from '@joplin/lib/services/interop/types';
import { unique } from '@joplin/lib/array';
import Folder from '@joplin/lib/models/Folder';
class Command extends BaseCommand {
public override usage() {
@@ -32,14 +33,16 @@ class Command extends BaseCommand {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public override async action(args: any) {
const folder = await app().loadItem(BaseModel.TYPE_FOLDER, args.notebook);
let destinationFolder = await app().loadItem(BaseModel.TYPE_FOLDER, args.notebook);
if (args.notebook && !folder) throw new Error(_('Cannot find "%s".', args.notebook));
if (args.notebook && !destinationFolder) throw new Error(_('Cannot find "%s".', args.notebook));
if (!destinationFolder) destinationFolder = await Folder.defaultFolder();
const importOptions: ImportOptions = {};
importOptions.path = args.path;
importOptions.format = args.options.format ? args.options.format : 'auto';
importOptions.destinationFolderId = folder ? folder.id : null;
importOptions.destinationFolderId = destinationFolder ? destinationFolder.id : null;
let lastProgress = '';

View File

@@ -0,0 +1,104 @@
import ShareService from '@joplin/lib/services/share/ShareService';
import mockShareService from '@joplin/lib/testing/share/mockShareService';
import { createFolderTree, setupDatabaseAndSynchronizer, switchClient, waitFor } from '@joplin/lib/testing/test-utils';
import { setupApplication, setupCommandForTesting } from './utils/testUtils';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
import Setting from '@joplin/lib/models/Setting';
const Command = require('./command-publish');
const setUpCommand = () => {
const onStdout = jest.fn();
const command = setupCommandForTesting(Command, onStdout);
return { command, onStdout };
};
describe('command-publish', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setupApplication();
mockShareService({
getShares: async () => {
return { items: [] };
},
postShares: async () => ({ id: 'test-id' }),
getShareInvitations: async () => null,
}, ShareService.instance());
});
test('should publish a note', async () => {
const { command, onStdout } = setUpCommand();
const testFolder = await Folder.save({ title: 'Test' });
const testNote = await Note.save({ title: 'test', parent_id: testFolder.id });
await command.action({
note: testNote.id,
options: {
force: true,
},
});
// Should be shared
await waitFor(async () => {
expect(await Note.load(testNote.id)).toMatchObject({
is_shared: 1,
});
});
// Should have logged the publication URL
expect(onStdout).toHaveBeenCalled();
expect(onStdout.mock.lastCall[0]).toMatch(/Published at URL:/);
});
test('should be enabled for Joplin Server and Cloud sync targets', () => {
const { command } = setUpCommand();
Setting.setValue('sync.target', 1);
expect(command.enabled()).toBe(false);
const supportedSyncTargets = [9, 10, 11];
for (const id of supportedSyncTargets) {
Setting.setValue('sync.target', id);
expect(command.enabled()).toBe(true);
}
});
test('should not ask for confirmation if a note is already published', async () => {
const { command } = setUpCommand();
const promptMock = jest.fn(() => true);
command.setPrompt(promptMock);
await createFolderTree('', [
{
title: 'folder 1',
children: [
{
title: 'note 1',
body: 'test',
},
],
},
]);
const noteId = (await Note.loadByTitle('note 1')).id;
// Should ask for confirmation when first sharing
await command.action({
note: noteId,
options: { },
});
expect(promptMock).toHaveBeenCalledTimes(1);
expect(await Note.load(noteId)).toMatchObject({ is_shared: 1 });
// Should not ask for confirmation if called again for the same note
await command.action({
note: noteId,
options: { },
});
expect(promptMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,64 @@
import { _ } from '@joplin/lib/locale';
import BaseCommand from './base-command';
import app from './app';
import Logger from '@joplin/utils/Logger';
import ShareService from '@joplin/lib/services/share/ShareService';
import { ModelType } from '@joplin/lib/BaseModel';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
import Setting from '@joplin/lib/models/Setting';
import { reg } from '@joplin/lib/registry';
const logger = Logger.create('command-publish');
type Args = {
note: string;
options: {
force?: boolean;
};
};
class Command extends BaseCommand {
public usage() {
return 'publish [note]';
}
public description() {
return _('Publishes a note to Joplin Server or Joplin Cloud');
}
public options() {
return [
['-f, --force', _('Do not ask for user confirmation.')],
];
}
public enabled() {
return SyncTargetRegistry.isJoplinServerOrCloud(Setting.value('sync.target'));
}
public async action(args: Args) {
const targetNote = await app().loadItemOrFail(ModelType.Note, args.note);
const parent = await app().loadItem(ModelType.Folder, targetNote.parent_id);
const force = args.options.force;
const alreadyShared = !!targetNote.is_shared;
const ok = force || alreadyShared ? true : await this.prompt(
_('Publish note "%s" (in notebook "%s")?', targetNote.title, parent.title ?? '<root>'),
{ booleanAnswerDefault: 'n' },
);
if (!ok) return;
logger.info('Share note: ', targetNote.id);
const share = await ShareService.instance().shareNote(targetNote.id, false);
this.stdout(_('Synchronising...'));
await reg.waitForSyncFinishedThenSync();
const userId = ShareService.instance().userId;
const shareUrl = ShareService.instance().shareUrl(userId, share);
this.stdout(_('Published at URL: %s', shareUrl));
}
}
module.exports = Command;

View File

@@ -149,6 +149,7 @@ class Command extends BaseCommand {
waiting: invitation.status === ShareUserStatus.Waiting,
rejected: invitation.status === ShareUserStatus.Rejected,
folderId: invitation.share.folder_id,
canWrite: !!invitation.can_write,
fromUser: {
email: invitation.share.user?.email,
},

View File

@@ -0,0 +1,43 @@
import ShareService from '@joplin/lib/services/share/ShareService';
import mockShareService from '@joplin/lib/testing/share/mockShareService';
import { setupDatabaseAndSynchronizer, switchClient, waitFor } from '@joplin/lib/testing/test-utils';
import { setupApplication, setupCommandForTesting } from './utils/testUtils';
import Note from '@joplin/lib/models/Note';
import Folder from '@joplin/lib/models/Folder';
const Command = require('./command-unpublish');
describe('command-unpublish', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
await setupApplication();
mockShareService({
getShares: async () => {
return { items: [{ id: 'test-id' }] };
},
postShares: async () => {
throw new Error('Unexpected call to postShares');
},
getShareInvitations: async () => null,
}, ShareService.instance());
});
test('should unpublish a note', async () => {
const command = setupCommandForTesting(Command, ()=>{});
const testFolder = await Folder.save({ title: 'Test' });
const testNote = await Note.save({ title: 'test', parent_id: testFolder.id, is_shared: 1 });
await command.action({
note: testNote.id,
});
await waitFor(async () => {
expect(await Note.load(testNote.id)).toMatchObject({
is_shared: 0,
});
});
});
});

View File

@@ -0,0 +1,57 @@
import { _ } from '@joplin/lib/locale';
import BaseCommand from './base-command';
import app from './app';
import Logger from '@joplin/utils/Logger';
import ShareService from '@joplin/lib/services/share/ShareService';
import { ModelType } from '@joplin/lib/BaseModel';
import Note from '@joplin/lib/models/Note';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
import Setting from '@joplin/lib/models/Setting';
import { reg } from '@joplin/lib/registry';
const logger = Logger.create('command-unpublish');
type Args = {
note: string;
};
class Command extends BaseCommand {
public usage() {
return 'publish [note]';
}
public description() {
return _('Publishes a note to Joplin Server or Joplin Cloud');
}
public options() {
return [
['-f, --force', _('Do not ask for user confirmation.')],
];
}
public enabled() {
return SyncTargetRegistry.isJoplinServerOrCloud(Setting.value('sync.target'));
}
public async action(args: Args) {
const targetNote = await app().loadItemOrFail(ModelType.Note, args.note);
if (!targetNote.is_shared) {
throw new Error(_('Note not published: %s', targetNote.title));
}
logger.info('Unshare note: ', targetNote.id);
await ShareService.instance().unshareNote(targetNote.id);
const note = await Note.load(targetNote.id);
if (note.is_shared) {
throw new Error('Assertion failure: The note is still shared.');
}
this.stdout(_('Synchronising...'));
await reg.waitForSyncFinishedThenSync();
}
}
module.exports = Command;

View File

@@ -35,7 +35,7 @@
],
"owner": "Laurent Cozic"
},
"version": "3.4.0",
"version": "3.4.1",
"bin": "./main.js",
"engines": {
"node": ">=10.0.0"
@@ -57,7 +57,7 @@
"proper-lockfile": "4.1.2",
"redux": "4.2.1",
"server-destroy": "1.0.1",
"sharp": "0.34.1",
"sharp": "0.34.2",
"sprintf-js": "1.1.3",
"sqlite3": "5.1.6",
"string-padding": "1.0.2",
@@ -73,7 +73,7 @@
"@joplin/tools": "~3.4",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "18.19.101",
"@types/node": "18.19.103",
"@types/proper-lockfile": "^4.1.2",
"gulp": "4.0.2",
"jest": "29.7.0",

View File

@@ -0,0 +1,3 @@
1. File without extension and leading `./`: [file1](./file1). Gets imported, but filename is converted to extension, like `<internal_id>.file1`
2. File without extension: [file2](file2). Not imported at all.
3. File with extension: [file3](file3.text). Gets imported properly.

View File

@@ -86,8 +86,14 @@ export default class InteropServiceHelper {
// pdfs.
// https://github.com/laurent22/joplin/issues/6254.
await win.webContents.executeJavaScript('document.querySelectorAll(\'details\').forEach(el=>el.setAttribute(\'open\',\'\'))');
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const data = await win.webContents.printToPDF(options as any);
const data = await win.webContents.printToPDF({
...options,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partially refactored old code before rule was applied
pageSize: options.pageSize as any,
// Allows users to override the CSS page size.
// See https://github.com/laurent22/joplin/issues/13096
preferCSSPageSize: true,
});
resolve(data);
} catch (error) {
reject(error);

View File

@@ -63,6 +63,8 @@ import { refreshFolders } from '@joplin/lib/folders-screen-utils';
import initializeCommandService from './utils/initializeCommandService';
import OcrDriverBase from '@joplin/lib/services/ocr/OcrDriverBase';
import PerformanceLogger from '@joplin/lib/PerformanceLogger';
import Note from '@joplin/lib/models/Note';
import Resource from '@joplin/lib/models/Resource';
const perfLogger = PerformanceLogger.create();
@@ -683,6 +685,11 @@ class Application extends BaseApplication {
debug: new DebugService(reg.db()),
resourceService: ResourceService.instance(),
searchEngine: SearchEngine.instance(),
shim,
Note,
Folder,
Resource,
Setting,
ocrService: () => this.ocrService_,
};
});

View File

@@ -15,7 +15,7 @@ import { connect } from 'react-redux';
import { AppState } from '../../app.reducer';
import Setting from '@joplin/lib/models/Setting';
import CommandService from '@joplin/lib/services/CommandService';
import { PublicPrivateKeyPair } from '@joplin/lib/services/e2ee/ppk';
import { PublicPrivateKeyPair } from '@joplin/lib/services/e2ee/ppk/ppk';
import ToggleAdvancedSettingsButton from '../ConfigScreen/controls/ToggleAdvancedSettingsButton';
import MacOSMissingPasswordHelpLink from '../ConfigScreen/controls/MissingPasswordHelpLink';

View File

@@ -67,6 +67,11 @@ import 'codemirror/mode/diff/diff';
import 'codemirror/mode/erlang/erlang';
import 'codemirror/mode/sql/sql';
interface ExtendedWindow {
CodeMirror?: unknown;
}
declare const window: ExtendedWindow;
export interface EditorProps {
value: string;
@@ -100,6 +105,14 @@ function Editor(props: EditorProps, ref: any) {
const editorParent = useRef(null);
const lastEditTime = useRef(NaN);
useEffect(() => {
window.CodeMirror = CodeMirror;
return () => {
window.CodeMirror = undefined;
};
}, []);
// Codemirror plugins add new commands to codemirror (or change it's behavior)
// This command adds the smartListIndent function which will be bound to tab
useListIdent(CodeMirror);

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import bridge from '../../../../../../services/bridge';
import { contentScriptsToCodeMirrorPlugin } from '@joplin/lib/services/plugins/utils/loadContentScripts';
import { extname } from 'path';
import shim from '@joplin/lib/shim';
@@ -7,6 +8,18 @@ import uuid from '@joplin/lib/uuid';
import { reg } from '@joplin/lib/registry';
const addPluginDependency = (path: string) => {
const id = `content-script-${encodeURIComponent(path)}`;
if (document.getElementById(id)) {
return;
}
const element = document.createElement('script');
element.setAttribute('id', id);
element.setAttribute('src', path);
document.head.appendChild(element);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export default function useExternalPlugins(CodeMirror: any, plugins: PluginStates) {
const [options, setOptions] = useState({});
@@ -23,7 +36,14 @@ export default function useExternalPlugins(CodeMirror: any, plugins: PluginState
if (mod.codeMirrorResources) {
for (const asset of mod.codeMirrorResources) {
try {
require(`codemirror/${asset}`);
let assetPath = shim.fsDriver().resolveRelativePathWithinDir(`${bridge().vendorDir()}/lib/codemirror/`, asset);
// Compatibility with old versions of Joplin, where the file extension was automatically added by require().
if (extname(assetPath) === '') {
assetPath += '.js';
}
addPluginDependency(assetPath);
} catch (error) {
error.message = `${asset} is not a valid CodeMirror asset, keymap or mode. You can find a list of valid assets here: https://codemirror.net/doc/manual.html#addons`;
throw error;

View File

@@ -30,6 +30,7 @@ import useEditorSearchHandler from '../utils/useEditorSearchHandler';
import CommandService from '@joplin/lib/services/CommandService';
import useRefocusOnVisiblePaneChange from './utils/useRefocusOnVisiblePaneChange';
import { WindowIdContext } from '../../../../NewWindowOrIFrame';
import eventManager, { EventName, ResourceChangeEvent } from '@joplin/lib/eventManager';
const logger = Logger.create('CodeMirror6');
const logDebug = (message: string) => logger.debug(message);
@@ -272,6 +273,17 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
props.noteId, props.useCustomPdfViewer,
]);
useEffect(() => {
const listener = (event: ResourceChangeEvent) => {
editorRef.current?.onResourceChanged(event.id);
};
eventManager.on(EventName.ResourceChange, listener);
return () => {
eventManager.off(EventName.ResourceChange, listener);
};
}, [props.resourceInfos]);
useEffect(() => {
if (!webviewReady) return;
@@ -366,6 +378,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
katexEnabled: Setting.value('markdown.plugin.katex'),
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
imageRenderingEnabled: Setting.value('editor.imageRendering'),
highlightActiveLine: Setting.value('editor.highlightActiveLine'),
themeData: {
...styles.globalTheme,
marginLeft: 0,

View File

@@ -110,11 +110,12 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
const editor = createEditor(editorContainerRef.current, {
...editorProps,
resolveImageSrc: async src => {
resolveImageSrc: async (src, reloadCounter) => {
const url = parseResourceUrl(src);
if (!url.itemId) return null;
const item = await Resource.load(url.itemId);
return `${getResourceBaseUrl()}/${resourceFilename(item)}`;
if (!item) return null;
return `${getResourceBaseUrl()}/${resourceFilename(item)}${reloadCounter ? `?r=${reloadCounter}` : ''}`;
},
});
editor.addStyles({

View File

@@ -13,6 +13,7 @@ import { MarkupToHtmlOptions } from '../../hooks/useMarkupToHtml';
import { ScrollbarSize } from '@joplin/lib/models/settings/builtInMetadata';
import { RefObject, SetStateAction } from 'react';
import * as React from 'react';
import { ResourceEntity, ResourceLocalStateEntity } from '@joplin/lib/services/database/types';
export interface AllAssetsOptions {
contentMaxWidthTarget?: string;
@@ -214,10 +215,8 @@ export function defaultFormNote(): FormNote {
}
export interface ResourceInfo {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
localState: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
item: any;
localState: ResourceLocalStateEntity;
item: ResourceEntity;
}
export interface ResourceInfos {

View File

@@ -251,8 +251,6 @@ export default class PromptDialog extends React.Component<Props, any> {
} else {
onClose(true);
}
} else if (event.key === 'Escape') {
onClose(false);
}
};
@@ -309,7 +307,7 @@ export default class PromptDialog extends React.Component<Props, any> {
}
return (
<Dialog className='prompt-dialog' contentStyle={styles.dialog}>
<Dialog className='prompt-dialog' contentStyle={styles.dialog} onCancel={() => onClose(false, 'cancel')}>
<label style={styles.label}>{this.props.label ? this.props.label : ''}</label>
<div style={{ display: 'inline-block', color: 'black', backgroundColor: theme.backgroundColor }}>
{inputComp}

View File

@@ -72,4 +72,10 @@ export default class MainScreen {
await setFilePickerResponse(electronApp, [path]);
await activateMainMenuItem(electronApp, 'HTML - HTML document (Directory)', 'Import');
}
public async pluginPanelLocator(pluginId: string) {
return this.page.locator(
`iframe[id^=${JSON.stringify(`plugin-view-${pluginId}`)}]`,
);
}
}

View File

@@ -45,6 +45,41 @@ test.describe('pluginApi', () => {
}));
});
test('should report the correct visibility state for dialogs', async ({ startAppWithPlugins }) => {
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/dialogs.js']);
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.createNewNote('Dialog test note');
const editor = mainScreen.noteEditor;
const expectVisible = async (visible: boolean) => {
// Check UI visibility
if (visible) {
await expect(mainScreen.dialog).toBeVisible();
} else {
await expect(mainScreen.dialog).not.toBeVisible();
}
// Check visibility reported through the plugin API
await expect.poll(async () => {
await mainScreen.goToAnything.runCommand(app, 'getTestDialogVisibility');
const editorContent = await editor.contentLocator();
return editorContent.textContent();
}).toBe(JSON.stringify({
visible: visible,
active: visible,
}));
};
await expectVisible(false);
await mainScreen.goToAnything.runCommand(app, 'showTestDialog');
await expectVisible(true);
// Submitting the dialog should include form data in the output
await mainScreen.dialog.getByRole('button', { name: 'Okay' }).click();
await expectVisible(false);
});
test('should be possible to create multiple toasts with the same text from a plugin', async ({ startAppWithPlugins }) => {
const { app, mainWindow } = await startAppWithPlugins(['resources/test-plugins/showToast.js']);
const mainScreen = await new MainScreen(mainWindow).setup();
@@ -122,5 +157,30 @@ test.describe('pluginApi', () => {
await msleep(Second);
await expect(noteEditor.codeMirrorEditor).toHaveText(expectedUpdatedText);
});
test('should support hiding and showing panels', async ({ startAppWithPlugins }) => {
const { mainWindow, app } = await startAppWithPlugins(['resources/test-plugins/panels.js']);
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.createNewNote('Test note (panels)');
const panelLocator = await mainScreen.pluginPanelLocator('org.joplinapp.plugins.example.panels');
const noteEditor = mainScreen.noteEditor;
await mainScreen.goToAnything.runCommand(app, 'testShowPanel');
await expect(noteEditor.codeMirrorEditor).toHaveText('visible');
// Panel should be visible
await expect(panelLocator).toBeVisible();
// The panel should have the expected content
const panelContent = panelLocator.contentFrame();
await expect(
panelContent.getByRole('heading', { name: 'Panel content' }),
).toBeAttached();
await mainScreen.goToAnything.runCommand(app, 'testHidePanel');
await expect(noteEditor.codeMirrorEditor).toHaveText('hidden');
await expect(panelLocator).not.toBeVisible();
});
});

View File

@@ -47,5 +47,22 @@ joplin.plugins.register({
}));
},
});
await joplin.commands.register({
name: 'getTestDialogVisibility',
label: 'Returns the dialog visibility state',
execute: async () => {
// panels.visible should also work for dialogs.
const visible = await joplin.views.panels.visible(dialogHandle);
// For dialogs, isActive should return the visibility.
// (Prefer panels.visible for dialogs).
const active = await joplin.views.panels.isActive(dialogHandle);
await joplin.commands.execute('editor.setText', JSON.stringify({
visible,
active,
}));
},
});
},
});

View File

@@ -0,0 +1,71 @@
// Allows referencing the Joplin global:
/* eslint-disable no-undef */
// Allows the `joplin-manifest` block comment:
/* eslint-disable multiline-comment-style */
/* joplin-manifest:
{
"id": "org.joplinapp.plugins.example.panels",
"manifest_version": 1,
"app_min_version": "3.1",
"name": "JS Bundle test",
"description": "JS Bundle Test plugin",
"version": "1.0.0",
"author": "",
"homepage_url": "https://joplinapp.org"
}
*/
const waitFor = async (condition) => {
const wait = () => {
return new Promise(resolve => {
setTimeout(() => resolve(), 100);
});
};
for (let i = 0; i < 100; i++) {
if (await condition()) {
return;
}
// Pause for a brief delay
await wait();
}
throw new Error('Condition was never true');
};
joplin.plugins.register({
onStart: async function() {
const panels = joplin.views.panels;
const view = await panels.create('panelTestView');
await panels.setHtml(view, '<h1>Panel content</h1><p>Test</p>');
await panels.hide(view);
await joplin.commands.register({
name: 'testShowPanel',
label: 'Test panel visibility',
execute: async () => {
await panels.show(view);
await waitFor(async () => {
return await panels.visible(view);
});
await joplin.commands.execute('editor.setText', 'visible');
},
});
await joplin.commands.register({
name: 'testHidePanel',
label: 'Test: Hide the panel',
execute: async () => {
await panels.hide(view);
await waitFor(async () => {
return !await panels.visible(view);
});
await joplin.commands.execute('editor.setText', 'hidden');
},
});
},
});

View File

@@ -53,6 +53,10 @@ const { rootProfileDir } = determineBaseAppDirs(profileFromArgs, appName, altIns
// various places early in the initialisation code.
mkdirpSync(rootProfileDir);
// Required for correct display of Windows notifications. Should be done near the beginning of startup. See
// https://www.electron.build/nsis.html#guid-vs-application-name
electronApp.setAppUserModelId(appId);
const settingsPath = `${rootProfileDir}/settings.json`;
let autoUploadCrashDumps = false;

View File

@@ -1,6 +1,6 @@
{
"name": "@joplin/app-desktop",
"version": "3.4.5",
"version": "3.4.11",
"description": "Joplin for Desktop",
"main": "main.bundle.js",
"private": true,
@@ -147,8 +147,8 @@
"@testing-library/react-hooks": "8.0.1",
"@types/jest": "29.5.14",
"@types/mustache": "4.2.6",
"@types/node": "18.19.101",
"@types/react": "18.3.21",
"@types/node": "18.19.103",
"@types/react": "18.3.23",
"@types/react-dom": "18.3.7",
"@types/react-redux": "7.1.33",
"@types/styled-components": "5.1.32",
@@ -160,7 +160,7 @@
"compare-versions": "6.1.1",
"countable": "3.0.1",
"debounce": "1.2.1",
"electron": "35.5.1",
"electron": "37.4.0",
"electron-builder": "24.13.3",
"electron-updater": "6.6.2",
"electron-window-state": "5.0.3",
@@ -179,7 +179,6 @@
"moment": "2.30.1",
"mustache": "4.2.0",
"nan": "2.22.2",
"node-fetch": "2.6.7",
"node-notifier": "10.0.1",
"node-rsa": "1.1.1",
"pdfjs-dist": "3.11.174",
@@ -211,6 +210,7 @@
"@joplin/onenote-converter": "~3.4",
"fs-extra": "11.2.0",
"keytar": "7.9.0",
"node-fetch": "2.6.7",
"sqlite3": "5.1.6"
}
}

View File

@@ -30,7 +30,7 @@ const makeBuildContext = (entryPoint: string, renderer: boolean, computeFileSize
// in the final bundle.
name: 'joplin--relative-imports-for-externals',
setup: build => {
const externalRegex = /^(.*\.node|sqlite3|electron|@electron\/remote\/.*|electron\/.*|@mapbox\/node-pre-gyp|jsdom)$/;
const externalRegex = /^(.*\.node|sqlite3|node-fetch|electron|@electron\/remote\/.*|electron\/.*|@mapbox\/node-pre-gyp|jsdom)$/;
build.onResolve({ filter: externalRegex }, args => {
// Electron packages don't need relative requires
if (args.path === 'electron' || args.path.startsWith('electron/')) {

View File

@@ -82,7 +82,7 @@ async function main() {
const files = [
'@fortawesome/fontawesome-free/css/all.min.css',
'@joeattardi/emoji-button/dist/index.js',
'codemirror/addon/dialog/dialog.css',
'codemirror/addon/',
'codemirror/lib/codemirror.css',
'mark.js/dist/mark.min.js',
'roboto-fontface/css/roboto/roboto-fontface.css',

View File

@@ -25,7 +25,7 @@ async function main() {
// wrong one. However it means it will have to be manually upgraded for each
// new Electron release. Some ABI map there:
// https://github.com/electron/node-abi/tree/master/test
const forceAbiArgs = '--force-abi 134';
const forceAbiArgs = '--force-abi 138';
if (isWindows()) {
// Cannot run this in parallel, or the 64-bit version might end up

View File

@@ -89,8 +89,8 @@ android {
applicationId "net.cozic.joplin"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 2097777
versionName "3.4.4"
versionCode 2097780
versionName "3.4.7"
ndk {
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
}
@@ -100,6 +100,8 @@ android {
externalNativeBuild {
cmake {
cppFlags '-DCMAKE_BUILD_TYPE=Release'
// For 16 KB pages. This should be removable after upgrading to NDK r28
arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
}
}
}

View File

@@ -38,8 +38,9 @@ add_library(${CMAKE_PROJECT_NAME} SHARED
set(WHISPER_LIB_DIR ${CMAKE_SOURCE_DIR}/../../../../vendor/whisper.cpp)
# Based on the Whisper.cpp Android example:
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -O3 ")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -fvisibility=hidden -fvisibility-inlines-hidden -ffunction-sections -fdata-sections")
set(SHARED_FLAGS "-O3 ")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${SHARED_FLAGS} ")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${SHARED_FLAGS} -fvisibility=hidden -fvisibility-inlines-hidden -ffunction-sections -fdata-sections")
# Whisper: See https://stackoverflow.com/a/76290722
add_subdirectory(${WHISPER_LIB_DIR} ./whisper)

View File

@@ -54,6 +54,14 @@ const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
logger.error(message);
}, []);
const isReadyRef = useRef(false);
const onCameraReady = useCallback(() => {
if (isReadyRef.current) return; // Already emitted
isReadyRef.current = true;
props.onCameraReady();
}, [props.onCameraReady]);
useAsyncEffect(async (event) => {
// iOS issue workaround: Since upgrading to Expo SDK 52, closing and reopening the camera on iOS
// never emits onCameraReady. As a workaround, call .resumePreview and wait for it to resolve,
@@ -63,16 +71,16 @@ const Camera = (props: Props, ref: ForwardedRef<CameraRef>) => {
// Instead, wait for the preview to start using resumePreview:
await camera.resumePreview();
if (event.cancelled) return;
props.onCameraReady();
onCameraReady();
}
}, [camera, props.onCameraReady]);
}, [camera, onCameraReady]);
return hasPermission?.granted ? <CameraView
ref={setCamera}
style={props.style}
facing={props.cameraType === CameraDirection.Front ? 'front' : 'back'}
ratio={props.ratio as CameraRatio}
onCameraReady={Platform.OS === 'android' ? props.onCameraReady : undefined}
onCameraReady={onCameraReady}
onMountError={onMountError}
animateShutter={false}
barcodeScannerSettings={barcodeScannerSettings}

View File

@@ -11,6 +11,7 @@ import SearchInput from './SearchInput';
import focusView from '../utils/focusView';
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
import NestableFlatList, { NestableFlatListControl } from './NestableFlatList';
import useKeyboardState from '../utils/hooks/useKeyboardState';
const naturalCompare = require('string-natural-compare');
@@ -65,7 +66,7 @@ const useSearchResults = ({
}: UseSearchResultsOptions) => {
const results = useMemo(() => {
return options
.filter(option => option.title.startsWith(search))
.filter(option => option.title.toLowerCase().includes(search))
.sort((a, b) => {
if (a.title === b.title) return 0;
// Full matches should go first
@@ -151,7 +152,12 @@ const useSelectedIndex = (search: string, searchResults: Option[]) => {
};
const useStyles = (themeId: number, showSearchResults: boolean) => {
const { fontScale } = useWindowDimensions();
const { fontScale, height: screenHeight } = useWindowDimensions();
const { dockedKeyboardHeight: keyboardHeight } = useKeyboardState();
// Allow the search results size to decrease when the keyboard is visible.
const searchResultsHeight = Math.max(128, Math.min(200, (screenHeight - keyboardHeight) / 3));
const menuItemHeight = 40 * fontScale;
const theme = themeStyle(themeId);
@@ -187,7 +193,7 @@ const useStyles = (themeId: number, showSearchResults: boolean) => {
minHeight: 32,
},
searchResults: {
height: 200,
height: searchResultsHeight,
flexGrow: 1,
flexShrink: 1,
...(showSearchResults ? {} : {
@@ -220,7 +226,7 @@ const useStyles = (themeId: number, showSearchResults: boolean) => {
backgroundColor: theme.selectedColor,
},
});
}, [theme, menuItemHeight, showSearchResults]);
}, [theme, menuItemHeight, searchResultsHeight, showSearchResults]);
return { menuItemHeight, styles };
};
@@ -248,6 +254,8 @@ const SearchResult: React.FC<SearchResultProps> = ({
<View style={[styles.optionContent, selected && styles.optionContentSelected]}>
{icon}
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={styles.optionLabel}
>{text}</Text>
</View>
@@ -452,10 +460,11 @@ const useInputEventHandlers = ({
} else if (key === 'ArrowUp') {
selectedIndexControl.onPreviousResult();
event.preventDefault();
} else if (key === 'Enter') {
} else if (key === 'Enter' && Platform.OS === 'web') {
// This case is necessary on web to prevent the
// search input from becoming defocused after
// pressing "enter".
// pressing "enter". Enter key behavior is handled
// elsewhere for other platforms.
event.preventDefault();
onSubmit();
setSearch('');
@@ -540,6 +549,7 @@ const ComboBox: React.FC<Props> = ({
};
const activeId = `${baseId}-${selectedIndex}`;
const searchResults = <NestableFlatList
keyboardShouldPersistTaps="handled"
ref={listRef}
data={results}
{...searchResultProps}
@@ -577,6 +587,7 @@ const ComboBox: React.FC<Props> = ({
onChangeText={setSearch}
onKeyPress={onKeyPress}
onSubmitEditing={onSubmit}
submitBehavior='submit'
placeholder={placeholder}
aria-activedescendant={showSearchResults ? activeId : undefined}
aria-controls={`menuBox-${baseId}`}

View File

@@ -1,7 +1,8 @@
import * as React from 'react';
import { TouchableOpacity, TouchableWithoutFeedback, Dimensions, Text, Modal, View, LayoutRectangle, ViewStyle, TextStyle, FlatList } from 'react-native';
import { TouchableOpacity, TouchableWithoutFeedback, Dimensions, Text, Modal, View, LayoutRectangle, ViewStyle, TextStyle, FlatList, Platform } from 'react-native';
import { Component, ReactElement } from 'react';
import { _ } from '@joplin/lib/locale';
import { EdgeInsets, SafeAreaInsetsContext } from 'react-native-safe-area-context';
type ValueType = string;
export interface DropdownListItem {
@@ -56,25 +57,43 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
};
}
private updateHeaderCoordinates = () => {
private updateHeaderCoordinates = (insets: EdgeInsets) => {
if (!this.headerRef) return;
// https://stackoverflow.com/questions/30096038/react-native-getting-the-position-of-an-element
this.headerRef.measure((_fx, _fy, width, height, px, py) => {
const lastLayout = this.state.headerSize;
let offsetX = 0;
let offsetY = 0;
// The opening position of the dropdown must be offset to cater for insets, on newer versions of Android which use edge to edge by default
// If the dropdown fills the full height of the screen, the offset gets ignored and does not cause anything to be truncated
if (Platform.OS === 'android' && Platform.Version >= 35) {
const windowHeight = Dimensions.get('window').height;
const windowWidth = Dimensions.get('window').width;
const isLandscape = windowWidth > windowHeight;
if (isLandscape) {
offsetX = insets.left;
offsetY = insets.top;
} else {
offsetY = insets.top;
}
}
if (px !== lastLayout.x || py !== lastLayout.y || width !== lastLayout.width || height !== lastLayout.height) {
this.setState({
headerSize: { x: px, y: py, width: width, height: height },
headerSize: { x: px - offsetX, y: py - offsetY, width: width, height: height },
});
}
});
};
private onOpenList = () => {
private onOpenList = (insets: EdgeInsets) => {
// On iOS, we need to re-measure just before opening the list. Measurements from just after
// onLayout can be inaccurate in some cases (in the past, this had caused the menu to be
// drawn far offscreen).
this.updateHeaderCoordinates();
this.updateHeaderCoordinates(insets);
this.setState({ listVisible: true });
};
private onCloseList = () => {
@@ -92,10 +111,16 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
}
};
public render() {
private renderWithInsets(insets: EdgeInsets) {
let offsetHeight = 0;
if (Platform.OS === 'android' && Platform.Version >= 35) {
offsetHeight = insets.bottom;
}
const items = this.props.items;
const itemHeight = 60;
const windowHeight = Dimensions.get('window').height - 50;
const windowHeight = Dimensions.get('window').height - 50 - offsetHeight;
const windowWidth = Dimensions.get('window').width;
// Dimensions doesn't return quite the right dimensions so leave an extra gap to make
@@ -205,13 +230,13 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
<View style={{ flex: 1, flexDirection: 'column' }}>
<View
style={{ flexDirection: 'row', flex: 1, alignItems: 'center' }}
onLayout={this.updateHeaderCoordinates}
onLayout={() => this.updateHeaderCoordinates(insets)}
ref={ref => { this.headerRef = ref; } }
>
<TouchableOpacity
style={headerWrapperStyle}
disabled={this.props.disabled}
onPress={this.onOpenList}
onPress={() => this.onOpenList(insets)}
accessibilityRole='button'
accessibilityHint={[this.props.accessibilityHint, _('Opens dropdown')].join(' ')}
>
@@ -268,6 +293,14 @@ class Dropdown extends Component<DropdownProps, DropdownState> {
</View>
);
}
public render() {
return (
<SafeAreaInsetsContext.Consumer>
{(insets) => this.renderWithInsets(insets)}
</SafeAreaInsetsContext.Consumer>
);
}
}
export default Dropdown;

View File

@@ -0,0 +1,132 @@
import * as React from 'react';
import { Store } from 'redux';
import { AppState } from '../utils/types';
import TestProviderStack from './testing/TestProviderStack';
import { switchClient, setupDatabase, mockMobilePlatform, mockFetch, waitFor } from '@joplin/lib/testing/test-utils';
import createMockReduxStore from '../utils/testing/createMockReduxStore';
import setupGlobalStore from '../utils/testing/setupGlobalStore';
import { act, fireEvent, render, screen } from '@testing-library/react-native';
import FeedbackBanner from './FeedbackBanner';
interface WrapperProps { }
let store: Store<AppState>;
const WrappedFeedbackBanner: React.FC<WrapperProps> = () => {
return <TestProviderStack store={store}>
<FeedbackBanner/>
</TestProviderStack>;
};
const getFeedbackButton = (positive: boolean) => {
return screen.getByRole('button', { name: positive ? 'Useful' : 'Not useful' });
};
const getSurveyLink = () => {
return screen.getByRole('button', { name: 'Take survey' });
};
const mockFeedbackServer = (surveyName = 'web-app-test') => {
let helpfulCount = 0;
let unhelpfulCount = 0;
const { reset } = mockFetch((request) => {
const surveyBaseUrls = [
'https://objects.joplinusercontent.com/',
'http://localhost:3430/',
];
const isSurveyRequest = surveyBaseUrls.some(url => request.url.startsWith(url));
if (!isSurveyRequest) {
return null;
}
const url = new URL(request.url);
if (url.pathname === `/r/survey--${surveyName}--helpful`) {
helpfulCount ++;
} else if (url.pathname === `/r/survey--${surveyName}--unhelpful`) {
unhelpfulCount ++;
} else {
return new Response('Not found', { status: 404 });
}
// The feedback server always redirects to another URL after a
// successful request. Mock this by always redirecting to the
// same URL.
return new Response('', {
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/302
status: 302,
statusText: 'Found',
headers: [
['location', 'https://joplinapp.org'],
],
});
});
return {
reset,
get helpfulCount() {
return helpfulCount;
},
get unhelpfulCount() {
return unhelpfulCount;
},
};
};
describe('FeedbackBanner', () => {
const resetMobilePlatform = ()=>{};
beforeEach(async () => {
await setupDatabase(0);
await switchClient(0);
store = createMockReduxStore();
setupGlobalStore(store);
jest.useFakeTimers({ advanceTimers: true });
mockMobilePlatform('web');
});
afterEach(() => {
screen.unmount();
resetMobilePlatform();
});
test.each([
{ platform: 'android', shouldShow: false },
{ platform: 'web', shouldShow: true },
{ platform: 'ios', shouldShow: false },
])('should correctly show/hide the feedback banner on %s', ({ platform, shouldShow }) => {
mockMobilePlatform(platform);
render(<WrappedFeedbackBanner />);
const header = screen.queryByRole('header', { name: 'Feedback' });
if (shouldShow) {
expect(header).toBeVisible();
} else {
expect(header).toBeNull();
}
});
test('clicking the "Useful" button should submit the response and show the "take survey" link', async () => {
const feedbackServerMock = mockFeedbackServer();
render(<WrappedFeedbackBanner />);
try {
const usefulButton = getFeedbackButton(true);
fireEvent.press(usefulButton);
await act(() => waitFor(async () => {
expect(getSurveyLink()).toBeVisible();
}));
expect(feedbackServerMock).toMatchObject({
helpfulCount: 1,
unhelpfulCount: 0,
});
} finally {
feedbackServerMock.reset();
}
});
});

View File

@@ -0,0 +1,216 @@
import { _ } from '@joplin/lib/locale';
import * as React from 'react';
import { View, StyleSheet, useWindowDimensions, TextStyle, Linking } from 'react-native';
import { Portal, Text } from 'react-native-paper';
import IconButton from './IconButton';
import { useCallback, useMemo } from 'react';
import shim from '@joplin/lib/shim';
import { Dispatch } from 'redux';
import { themeStyle } from './global-style';
import { AppState } from '../utils/types';
import { connect } from 'react-redux';
import Setting from '@joplin/lib/models/Setting';
import { LinkButton } from './buttons';
import Logger from '@joplin/utils/Logger';
import { SurveyProgress } from '@joplin/lib/models/settings/builtInMetadata';
const logger = Logger.create('FeedbackBanner');
interface Props {
dispatch: Dispatch;
progress: SurveyProgress;
surveyKey: string;
themeId: number;
}
const useStyles = (themeId: number, sentFeedback: boolean) => {
const { width: windowWidth } = useWindowDimensions();
return useMemo(() => {
const theme = themeStyle(themeId);
const iconBaseStyle: TextStyle = {
fontSize: 24,
color: theme.color3,
};
return StyleSheet.create({
container: {
backgroundColor: theme.backgroundColor3,
borderTopRightRadius: 16,
display: 'flex',
flexGrow: 1,
flexWrap: 'wrap',
flexDirection: 'row',
position: 'absolute',
bottom: 0,
left: 0,
maxWidth: windowWidth - 50,
gap: 18,
padding: 12,
},
contentRight: {
display: sentFeedback ? 'none' : 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 16,
},
header: {
fontWeight: 'bold',
},
iconUseful: {
...iconBaseStyle,
color: theme.colorCorrect,
},
iconNotUseful: {
...iconBaseStyle,
color: theme.colorWarn,
},
dismissButtonIcon: {
fontSize: 16,
color: theme.color2,
marginLeft: 'auto',
marginRight: 'auto',
},
dismissButton: {
backgroundColor: theme.backgroundColor2,
borderColor: theme.backgroundColor,
borderWidth: 2,
width: 29,
height: 29,
borderRadius: 14,
position: 'absolute',
top: -16,
right: -16,
justifyContent: 'center',
},
dismissButtonContent: {
flexShrink: 1,
},
});
}, [themeId, windowWidth, sentFeedback]);
};
const useSurveyUrl = (surveyKey: string) => {
return useMemo(() => {
let baseUrl = 'https://objects.joplinusercontent.com/';
// For testing with a locally-hosted server:
const useLocalServer = false;
if (Setting.value('env') === 'dev' && useLocalServer) {
baseUrl = 'http://localhost:3430/';
}
return `${baseUrl}r/survey--${encodeURIComponent(surveyKey)}`;
}, [surveyKey]);
};
const setProgress = (progress: SurveyProgress) => {
Setting.setValue('survey.webClientEval2025.progress', progress);
};
const onDismiss = () => {
setProgress(SurveyProgress.Dismissed);
};
const FeedbackBanner: React.FC<Props> = props => {
const surveyUrl = useSurveyUrl(props.surveyKey);
const sentFeedback = props.progress === SurveyProgress.Started;
const sendSurveyResponse = useCallback(async (surveyResponse: string) => {
const fetchUrl = `${surveyUrl}--${encodeURIComponent(surveyResponse)}`;
logger.debug('sending response to', fetchUrl);
const showError = (message: string) => {
logger.error('Error', message);
void shim.showErrorDialog(
_('An error occurred while sending the response. This can happen if the app is offline or cannot connect to the server.\nError: %s', message),
);
};
try {
const response = await shim.fetch(fetchUrl);
// The server currently redirects (status 302) in response
// to many survey-related requests. This may be returned by
// the web app service worker as a 200 OK response, however. Support both:
if (response.ok || response.status === 302) {
setProgress(SurveyProgress.Started);
} else {
const body = await response.text();
showError(`Server error: ${response.status} ${body}`);
}
} catch (error) {
showError(error);
}
}, [surveyUrl]);
const onSurveyLinkClick = useCallback(() => {
void Linking.openURL(surveyUrl);
onDismiss();
}, [surveyUrl]);
const onNotUsefulClick = useCallback(() => {
void sendSurveyResponse('unhelpful');
}, [sendSurveyResponse]);
const onUsefulClick = useCallback(() => {
void sendSurveyResponse('helpful');
}, [sendSurveyResponse]);
const styles = useStyles(props.themeId, sentFeedback);
const renderStatusMessage = () => {
if (sentFeedback) {
return <View>
<Text>{_('Thank you for the feedback!\nDo you have time to complete a short survey?')}</Text>
<LinkButton onPress={onSurveyLinkClick}>{_('Take survey')}</LinkButton>
</View>;
} else {
return <Text>{_('Do you find the Joplin web app useful?')}</Text>;
}
};
if (shim.mobilePlatform() !== 'web' || props.progress === SurveyProgress.Dismissed) return null;
return <Portal>
<View style={styles.container} role='complementary'>
<View>
<Text
accessibilityRole='header'
variant='titleMedium'
style={styles.header}
>{_('Feedback')}</Text>
<Text>{renderStatusMessage()}</Text>
</View>
<View style={styles.contentRight}>
<IconButton
iconName='fas times'
themeId={props.themeId}
onPress={onNotUsefulClick}
description={_('Not useful')}
iconStyle={styles.iconNotUseful}
/>
<IconButton
iconName='fas check'
themeId={props.themeId}
onPress={onUsefulClick}
description={_('Useful')}
iconStyle={styles.iconUseful}
/>
</View>
<IconButton
iconName='fas times'
themeId={props.themeId}
onPress={onDismiss}
description={_('Dismiss')}
iconStyle={styles.dismissButtonIcon}
contentWrapperStyle={styles.dismissButtonContent}
containerStyle={styles.dismissButton}
/>
</View>
</Portal>;
};
export default connect((state: AppState) => ({
themeId: state.settings.theme,
surveyKey: 'web-app-test',
progress: state.settings['survey.webClientEval2025.progress'],
}))(FeedbackBanner);

View File

@@ -87,6 +87,14 @@ const IconButton = (props: ButtonProps) => {
props.preventKeyboardDismiss, props.onPress, props.disabled,
);
let icon = <Icon
name={props.iconName}
style={props.iconStyle}
accessibilityLabel={null}
/>;
// Include browser-provided tooltips on web.
icon = Platform.OS === 'web' ? <span title={props.description}>{icon}</span> : icon;
const button = (
<Pressable
ref={props.pressableRef}
@@ -115,11 +123,7 @@ const IconButton = (props: ButtonProps) => {
opacity: fadeAnim,
...props.contentWrapperStyle,
}}>
<Icon
name={props.iconName}
style={props.iconStyle}
accessibilityLabel={null}
/>
{icon}
</Animated.View>
</Pressable>
);

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { RefObject, useCallback, useMemo, useRef, useState } from 'react';
import { GestureResponderEvent, Modal, ModalProps, Platform, Pressable, ScrollView, ScrollViewProps, StyleSheet, View, ViewStyle } from 'react-native';
import { GestureResponderEvent, KeyboardAvoidingView, Modal, ModalProps, Platform, Pressable, ScrollView, ScrollViewProps, StyleSheet, View, ViewStyle } from 'react-native';
import FocusControl from './accessibility/FocusControl/FocusControl';
import { msleep, Second } from '@joplin/utils/time';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
@@ -8,7 +8,7 @@ import { ModalState } from './accessibility/FocusControl/types';
import useSafeAreaPadding from '../utils/hooks/useSafeAreaPadding';
import { _ } from '@joplin/lib/locale';
interface ModalElementProps extends ModalProps {
export interface ModalElementProps extends ModalProps {
children: React.ReactNode;
containerStyle?: ViewStyle;
backgroundColor?: string;
@@ -27,11 +27,23 @@ interface ModalElementProps extends ModalProps {
const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) => {
const safeAreaPadding = useSafeAreaPadding();
return useMemo(() => {
// On Android, the top-level container seems to need to be absolutely positioned
// to prevent it from being larger than the screen size:
const absoluteFill = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
} satisfies ViewStyle;
return StyleSheet.create({
modalBackground: {
...safeAreaPadding,
flexGrow: 1,
flexShrink: 1,
...(hasScrollView ? {
flexGrow: 1,
flexShrink: 1,
} : absoluteFill),
// When hasScrollView, the modal background is wrapped in a ScrollView. In this case, it's
// possible to scroll content outside the background into view. To prevent the edge of the
@@ -39,6 +51,10 @@ const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) =>
// instead:
backgroundColor: hasScrollView ? null : backgroundColor,
},
keyboardAvoidingView: {
...absoluteFill,
flex: 1,
},
modalScrollView: {
backgroundColor,
flexGrow: 1,
@@ -159,11 +175,13 @@ const ModalElement: React.FC<ModalElementProps> = ({
{...modalProps}
>
{scrollOverflow ? (
<ScrollView
{...extraScrollViewProps}
style={[styles.modalScrollView, extraScrollViewProps.style]}
contentContainerStyle={[styles.modalScrollViewContent, extraScrollViewProps.contentContainerStyle]}
>{contentAndBackdrop}</ScrollView>
<KeyboardAvoidingView behavior='padding' style={styles.keyboardAvoidingView}>
<ScrollView
{...extraScrollViewProps}
style={[styles.modalScrollView, extraScrollViewProps.style]}
contentContainerStyle={[styles.modalScrollViewContent, extraScrollViewProps.contentContainerStyle]}
>{contentAndBackdrop}</ScrollView>
</KeyboardAvoidingView>
) : contentAndBackdrop}
</Modal>
</FocusControl.ModalWrapper>

View File

@@ -3,7 +3,7 @@ import { useMemo } from 'react';
import { View, StyleSheet } from 'react-native';
import { themeStyle } from './global-style';
import Modal from './Modal';
import Modal, { ModalElementProps } from './Modal';
import { PrimaryButton } from './buttons';
import { _ } from '@joplin/lib/locale';
import { Button } from 'react-native-paper';
@@ -11,6 +11,7 @@ import { Button } from 'react-native-paper';
interface Props {
themeId: number;
children: React.ReactNode;
modalProps: Partial<ModalElementProps>;
buttonBarEnabled: boolean;
okTitle: string;
@@ -27,19 +28,15 @@ const useStyles = (themeId: number) => {
borderRadius: 4,
backgroundColor: theme.backgroundColor,
maxWidth: 600,
maxHeight: 500,
width: '100%',
height: '100%',
alignSelf: 'center',
marginVertical: 'auto',
flexGrow: 1,
flexShrink: 1,
padding: theme.margin,
},
title: theme.headerStyle,
contentWrapper: {
flexGrow: 1,
flexShrink: 1,
},
buttonRow: {
flexDirection: 'row',
@@ -66,6 +63,7 @@ const ModalDialog: React.FC<Props> = props => {
onRequestClose={null}
containerStyle={styles.container}
backgroundColor={theme.backgroundColorTransparent2}
{...props.modalProps}
>
<View style={styles.contentWrapper}>{props.children}</View>
<View style={styles.buttonRow}>

View File

@@ -84,7 +84,7 @@ const NestableFlatList = function<T>({
}, []);
const bufferSize = 10;
const visibleStartIndex = Math.floor(scroll / itemHeight);
const visibleStartIndex = Math.min(Math.floor(scroll / itemHeight), data.length);
const visibleEndIndex = Math.ceil((scroll + listHeight) / itemHeight);
const startIndex = Math.max(0, visibleStartIndex - bufferSize);
const maximumIndex = data.length - 1;

View File

@@ -16,6 +16,12 @@ import { ResourceInfo } from './hooks/useRerenderHandler';
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
import TestProviderStack from '../testing/TestProviderStack';
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import Plugin from '@joplin/lib/services/plugins/Plugin';
import { Store } from 'redux';
import { ContentScriptType } from '@joplin/lib/services/plugins/api/types';
import { basename, dirname, join } from 'path';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import mockPluginServiceSetup from '../../utils/testing/mockPluginServiceSetup';
interface WrapperProps {
noteBody: string;
@@ -28,7 +34,7 @@ interface WrapperProps {
const emptyObject = {};
const emptyArray: string[] = [];
const noOpFunction = () => {};
const testStore = createMockReduxStore();
let testStore: Store;
const WrappedNoteViewer: React.FC<WrapperProps> = (
{
noteBody,
@@ -58,10 +64,34 @@ const getNoteViewerDom = async () => {
return await getWebViewDomById('NoteBodyViewer');
};
const loadTestContentScript = async (path: string, pluginId: string) => {
const plugin = new Plugin(
dirname(path),
{
manifest_version: 1,
id: pluginId,
name: 'Test plugin',
version: '1',
app_min_version: '1',
},
'',
testStore.dispatch,
'',
);
await PluginService.instance().runPlugin(plugin);
await plugin.registerContentScript(
ContentScriptType.MarkdownItPlugin,
`${pluginId}-markdown-it`,
basename(path),
);
};
describe('NoteBodyViewer', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
testStore = createMockReduxStore();
mockPluginServiceSetup(testStore);
});
afterEach(() => {
@@ -85,6 +115,17 @@ describe('NoteBodyViewer', () => {
await expectHeaderToBe('Test 3');
});
it('should support basic renderer plugins', async () => {
await loadTestContentScript(join(supportDir, 'plugins', 'markdownItTestPlugin.js'), 'test-plugin');
render(<WrappedNoteViewer noteBody={'```justtesting\ntest\n```\n'}/>);
const noteViewer = await getNoteViewerDom();
await waitFor(async () => {
expect(noteViewer.querySelector('div.just-testing')).toBeTruthy();
});
});
it.each([
{ keywords: ['match'], body: 'A match and another match. Both should be highlighted.', expectedMatchCount: 2 },
{ keywords: ['test'], body: 'No match.', expectedMatchCount: 0 },

View File

@@ -3,13 +3,11 @@ import themeToCss from '@joplin/lib/services/style/themeToCss';
import ExtendedWebView from '../ExtendedWebView';
import * as React from 'react';
import { useEffect } from 'react';
import { useMemo, useCallback } from 'react';
import { NativeSyntheticEvent } from 'react-native';
import { EditorProps } from './types';
import { _ } from '@joplin/lib/locale';
import useCodeMirrorPlugins from './hooks/useCodeMirrorPlugins';
import { WebViewErrorEvent } from 'react-native-webview/lib/RNCWebViewNativeComponent';
import Logger from '@joplin/utils/Logger';
import { OnMessageEvent } from '../ExtendedWebView/types';
@@ -117,16 +115,16 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
onEditorEvent: props.onEditorEvent,
onAttachFile: props.onAttach,
editorOptions: {
parentElementClassName: 'CodeMirror',
parentElementOrClassName: 'CodeMirror',
initialText: props.initialText,
initialNoteId: props.noteId,
settings: props.editorSettings,
onLocalize: _,
},
webviewRef,
pluginStates: props.plugins,
});
props.editorRef.current = editorWebViewSetup.api.editor;
props.editorRef.current = editorWebViewSetup.api.mainEditor;
const injectedJavaScript = `
window.onerror = (message, source, lineno) => {
@@ -154,11 +152,6 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
const css = useCss(props.themeId);
const html = useHtml();
const codeMirrorPlugins = useCodeMirrorPlugins(props.plugins);
useEffect(() => {
void editorWebViewSetup.api.editor.setContentScripts(codeMirrorPlugins);
}, [codeMirrorPlugins, editorWebViewSetup]);
const onMessage = useCallback((event: OnMessageEvent) => {
const data = event.nativeEvent.data;
@@ -183,7 +176,7 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
html={html}
injectedJavaScript={injectedJavaScript}
css={css}
hasPluginScripts={codeMirrorPlugins.length > 0}
hasPluginScripts={editorWebViewSetup.hasPlugins}
onMessage={onMessage}
onLoadEnd={editorWebViewSetup.webViewEventHandlers.onLoadEnd}
onError={onError}

View File

@@ -7,7 +7,7 @@ import { WebViewControl } from '../ExtendedWebView/types';
import * as React from 'react';
import { Ref, RefObject, useEffect, useImperativeHandle } from 'react';
import { useMemo, useState, useCallback, useRef } from 'react';
import { LayoutChangeEvent, View, ViewStyle } from 'react-native';
import { LayoutChangeEvent, Platform, View, ViewStyle } from 'react-native';
import { editorFont } from '../global-style';
import { EditorControl as EditorBodyControl, ContentScriptData } from '@joplin/editor/types';
@@ -32,6 +32,7 @@ import { dirname } from '@joplin/utils/path';
import { toFileExtension } from '@joplin/lib/mime-utils';
import { MarkupLanguage } from '@joplin/renderer';
import WarningBanner from './WarningBanner';
import useIsScreenReaderEnabled from '../../utils/hooks/useIsScreenReaderEnabled';
type ChangeEventHandler = (event: ChangeEvent)=> void;
type UndoRedoDepthChangeHandler = (event: UndoRedoDepthChangeEvent)=> void;
@@ -229,8 +230,12 @@ const useEditorControl = (
setSearchState: setSearchStateCallback,
},
onResourceDownloaded: (id: string) => {
editorRef.current.onResourceDownloaded(id);
onResourceChanged: (id: string) => {
editorRef.current.onResourceChanged(id);
},
remove: () => {
editorRef.current.remove();
},
};
@@ -238,9 +243,18 @@ const useEditorControl = (
}, [webviewRef, editorRef, setLinkDialogVisible, setSearchState]);
};
const useHighlightActiveLine = () => {
const screenReaderEnabled = useIsScreenReaderEnabled();
// Guess whether highlighting the active line can be enabled without triggering
// https://github.com/codemirror/dev/issues/1559.
const canHighlight = Platform.OS !== 'ios' || !screenReaderEnabled;
return canHighlight && Setting.value('editor.highlightActiveLine');
};
function NoteEditor(props: Props) {
const webviewRef = useRef<WebViewControl>(null);
const highlightActiveLine = useHighlightActiveLine();
const editorSettings: EditorSettings = useMemo(() => ({
themeData: editorTheme(props.themeId),
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
@@ -251,6 +265,7 @@ function NoteEditor(props: Props) {
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
useExternalSearch: true,
readOnly: props.readOnly,
highlightActiveLine,
keymap: EditorKeymap.Default,
@@ -263,7 +278,7 @@ function NoteEditor(props: Props) {
indentWithTabs: true,
editorLabel: _('Markdown editor'),
}), [props.themeId, props.readOnly, props.markupLanguage]);
}), [props.themeId, props.readOnly, props.markupLanguage, highlightActiveLine]);
const [selectionState, setSelectionState] = useState<SelectionFormatting>(defaultSelectionFormatting);
const [linkDialogVisible, setLinkDialogVisible] = useState(false);
@@ -300,6 +315,7 @@ function NoteEditor(props: Props) {
editorControl.searchControl.hideSearch();
}
break;
case EditorEventType.Remove:
case EditorEventType.Scroll:
// Not handled
break;
@@ -326,10 +342,18 @@ function NoteEditor(props: Props) {
const isDownloaded = (resourceInfos: ResourceInfos, resourceId: string) => {
return resourceInfos[resourceId]?.localState?.fetch_status === Resource.FETCH_STATUS_DONE;
};
const isEncrypted = (resourceInfos: ResourceInfos, resourceId: string) => {
return resourceInfos[resourceId]?.item?.encryption_blob_encrypted === 1;
};
for (const key in props.noteResources) {
const wasDownloaded = isDownloaded(lastNoteResources.current, key);
if (!wasDownloaded && isDownloaded(props.noteResources, key)) {
editorControl.onResourceDownloaded(key);
const hasDownloaded = !wasDownloaded && isDownloaded(props.noteResources, key);
const wasEncrypted = isEncrypted(lastNoteResources.current, key);
const hasDecrypted = wasEncrypted && !isEncrypted(props.noteResources, key);
if (hasDownloaded || hasDecrypted) {
editorControl.onResourceChanged(key);
}
}
}, [props.noteResources, editorControl]);

View File

@@ -257,7 +257,7 @@ describe('RichTextEditor', () => {
ref={editorRef}
/>,
);
editorRef.current.onResourceDownloaded(localResource.id);
editorRef.current.onResourceChanged(localResource.id);
expect(
await findElement(`img[data-resource-id=${JSON.stringify(localResource.id)}]`),
@@ -288,6 +288,26 @@ describe('RichTextEditor', () => {
});
});
it('should avoid rendering URLs with unknown protocols', async () => {
let body = '[link](unknown://test)';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const renderedLink = await findElement<HTMLAnchorElement>('a[href][data-original-href]');
expect(renderedLink.getAttribute('href')).toBe('#');
expect(renderedLink.getAttribute('data-original-href')).toBe('unknown://test');
const window = await getEditorWindow();
mockTyping(window, ' testing');
await waitFor(async () => {
expect(body.trim()).toBe('[link](unknown://test) testing');
});
});
it.each([
MarkupLanguage.Markdown, MarkupLanguage.Html,
])('should preserve image attachments on edit (case %#)', async (markupLanguage) => {
@@ -370,6 +390,37 @@ describe('RichTextEditor', () => {
});
});
it('should be possible show an editor for math blocks', async () => {
let body = 'Test:\n\n$$3^2 + 4^2 = 5^2$$';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const editButton = await findElement<HTMLButtonElement>('button.edit');
editButton.click();
const editor = await findElement('dialog .cm-editor');
expect(editor).toBeTruthy();
expect(editor.textContent).toContain('3^2 + 4^2 = 5^2');
});
it('should save lists as single-spaced', async () => {
let body = 'Test:\n\n- this\n- is\n- a\n- test.';
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
const window = await getEditorWindow();
mockTyping(window, ' Testing');
await waitFor(async () => {
expect(body.trim()).toBe('Test:\n\n- this\n- is\n- a\n- test. Testing');
});
});
it('should preserve table of contents blocks on edit', async () => {
let body = '# Heading\n\n# Heading 2\n\n[toc]\n\nTest.';
@@ -391,4 +442,32 @@ describe('RichTextEditor', () => {
expect(body.trim()).toBe('# Heading\n\n# Heading 2\n\n[toc]\n\nTest. testing');
});
});
it.each([
'**bold**',
'*italic*',
'$\\text{math}$',
'<span style="color: red;">test</span>',
'`code`',
'==highlight==ed',
'<sup>Super</sup>script',
'<sub>Sub</sub>script',
])('should preserve inline markup on edit (case %#)', async (initialBody) => {
initialBody += 'test'; // Ensure that typing will add new content outside the formatting
let body = initialBody;
render(<WrappedEditor
noteBody={body}
onBodyChange={newBody => { body = newBody; }}
/>);
await findElement<HTMLElement>('div.prosemirror-editor');
const window = await getEditorWindow();
mockTyping(window, ' testing');
await waitFor(async () => {
expect(body.trim()).toBe(`${initialBody} testing`);
});
});
});

View File

@@ -37,6 +37,7 @@ const useStyles = (themeId: number) => {
const listItemPressable: ViewStyle = {
flexGrow: 1,
flexShrink: 1,
alignSelf: 'stretch',
};
const listItemPressableWithCheckbox: ViewStyle = {

View File

@@ -11,7 +11,7 @@ import { saveProfileConfig, switchProfile } from '../../services/profiles';
import { themeStyle } from '../global-style';
import shim from '@joplin/lib/shim';
import { DialogContext } from '../DialogManager';
import { FAB, List, Portal } from 'react-native-paper';
import { FAB, List } from 'react-native-paper';
import { TextStyle } from 'react-native';
import useOnLongPressProps from '../../utils/hooks/useOnLongPressProps';
import { Dispatch } from 'redux';
@@ -206,19 +206,17 @@ export default (props: Props) => {
extraData={extraListItemData}
/>
</View>
<Portal>
<FAB
icon="plus"
accessibilityLabel={_('New profile')}
style={style.fab}
onPress={() => {
props.dispatch({
type: 'NAV_GO',
routeName: 'ProfileEditor',
});
}}
/>
</Portal>
<FAB
icon="plus"
accessibilityLabel={_('New profile')}
style={style.fab}
onPress={() => {
props.dispatch({
type: 'NAV_GO',
routeName: 'ProfileEditor',
});
}}
/>
</View>
);
};

View File

@@ -7,6 +7,8 @@ import AccessibleView from '../accessibility/AccessibleView';
import debounce from '../../utils/debounce';
import FocusControl from '../accessibility/FocusControl/FocusControl';
import { ModalState } from '../accessibility/FocusControl/types';
import useKeyboardState from '../../utils/hooks/useKeyboardState';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
interface MenuOptionDivider {
isDivider: true;
@@ -29,7 +31,9 @@ interface Props {
}
const useStyles = (themeId: number) => {
const { height: windowHeight } = useWindowDimensions();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const safeAreaInsets = useSafeAreaInsets();
const { dockedKeyboardHeight: keyboardHeight } = useKeyboardState();
return useMemo(() => {
const theme = themeStyle(themeId);
@@ -46,6 +50,20 @@ const useStyles = (themeId: number) => {
fontSize: theme.fontSize,
};
const isLandscape = windowWidth > windowHeight;
const extraPadding = isLandscape ? 25 : 50;
// When a docked on-screen keyboard is showing, we want to maximise the height of the menu as much as possible, due to the limited available space.
// However, when the on-screen keyboard is hidden or floating in either portrait or landscape orientation, it is less of an issue to have excess in the amount
// of padding, to ensure nothing is cut off on all varieties of supported mobile platforms with different input and navigation bar settings. In particular,
// on Android it is not possible to distinguish between a floating keyboard and a horizontal input bar which is docked, but the latter requires a larger
// reduction in height. For this reason we use a fixed value for insetOrExtraFullscreenPadding when the keyboard height is zero. However, Android versions
// earlier than 15 have an IME toolbar in addition to the input toolbar when using an external keyboard, so to cater for this scenario, we can use the fixed
// value if the keyboardHeight is <= 25 (as any proper on-screen keyboard would be much taller than this). If the keyboard height is larger than this, we can assume
// a docked keyboard is visible, so we only need cater for the insets in addition to the fixed extraPadding required for compatibility across Android versions
const insetOrExtraFullscreenPadding = keyboardHeight <= 25 ? 70 : safeAreaInsets.top + safeAreaInsets.bottom;
const maxMenuHeight = windowHeight - keyboardHeight - extraPadding - insetOrExtraFullscreenPadding;
return StyleSheet.create({
divider: {
borderBottomWidth: 1,
@@ -66,13 +84,13 @@ const useStyles = (themeId: number) => {
opacity: 0.5,
},
menuContentScroller: {
maxHeight: windowHeight - 50,
maxHeight: maxMenuHeight,
},
contextMenuButton: {
padding: 0,
},
});
}, [themeId, windowHeight]);
}, [themeId, windowWidth, windowHeight, safeAreaInsets, keyboardHeight]);
};
const MenuComponent: React.FC<Props> = props => {

View File

@@ -1,12 +1,15 @@
import * as React from 'react';
import { Linking, TextStyle, View, ViewStyle } from 'react-native';
import { Linking, StyleSheet, TextStyle, View, ViewStyle } from 'react-native';
import { Text } from 'react-native-paper';
import IconButton from '../IconButton';
import { _ } from '@joplin/lib/locale';
import { useCallback, useState } from 'react';
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
import { LinkButton } from '../buttons';
import NavService from '@joplin/lib/services/NavService';
import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl';
import getPackageInfo from '../../utils/getPackageInfo';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import Setting from '@joplin/lib/models/Setting';
interface Props {
wrapperStyle: ViewStyle;
@@ -15,10 +18,24 @@ interface Props {
}
const onLeaveFeedback = () => {
void Linking.openURL('https://discourse.joplinapp.org/t/web-client-running-joplin-mobile-in-a-web-browser-with-react-native-web/38749');
void Linking.openURL('https://forms.gle/B5YGDNzsUYBnoPx19');
};
const feedbackContainerStyles: ViewStyle = { flexGrow: 1, justifyContent: 'flex-end' };
const onReportBug = () => {
void Linking.openURL(
makeDiscourseDebugUrl('', '', [], getPackageInfo(), PluginService.instance(), Setting.value('plugins.states')),
);
};
const styles = StyleSheet.create({
feedbackContainer: {
flexGrow: 1,
justifyContent: 'flex-end',
},
paragraph: {
paddingBottom: 7,
},
});
const WebBetaButton: React.FC<Props> = props => {
const [dialogVisible, setDialogVisible] = useState(false);
@@ -31,6 +48,10 @@ const WebBetaButton: React.FC<Props> = props => {
setDialogVisible(false);
}, []);
const renderParagraph = (content: string) => {
return <Text variant='bodyLarge' style={styles.paragraph}>{content}</Text>;
};
return (
<>
<IconButton
@@ -49,10 +70,13 @@ const WebBetaButton: React.FC<Props> = props => {
visible={dialogVisible}
onDismiss={onHideDialog}
>
<Text>{'At present, the web client is in beta. In the future, it may change significantly, or be removed.'}</Text>
<View style={feedbackContainerStyles}>
{renderParagraph('Welcome to the beta version of the Joplin Web App!')}
{renderParagraph('Thank you for participating in the beta version of the Joplin Web App.')}
{renderParagraph('The Joplin Web App is available for a limited time in open beta and may later join the Joplin Cloud plans.')}
{renderParagraph('Feel free to use it and let us know if have any questions or notice any issues!')}
<View style={styles.feedbackContainer}>
<LinkButton onPress={onReportBug}>{'Report bug'}</LinkButton>
<LinkButton onPress={onLeaveFeedback}>{'Give feedback'}</LinkButton>
<LinkButton onPress={() => NavService.go('DocumentScanner')}>{'Test work-in-progress feature: Document scanner'}</LinkButton>
</View>
</DismissibleDialog>
</>

View File

@@ -101,12 +101,24 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const styleObject: any = {
container: {
outerContainer: {
flexDirection: 'column',
},
innerContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.backgroundColor2,
shadowColor: '#000000',
elevation: 5,
},
// A small border above the header: Covers the part of the shadow that would otherwise
// be shown above the header on Android.
aboveHeader: {
backgroundColor: theme.backgroundColor2,
paddingBottom: 6,
marginTop: -6,
zIndex: 2,
},
sideMenuButton: {
flex: 1,
alignItems: 'center',
@@ -678,8 +690,9 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade
);
return (
<View style={this.styles().container}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={this.styles().outerContainer}>
<View style={this.styles().aboveHeader}/>
<View style={this.styles().innerContainer}>
{sideMenuComp}
{backButtonComp}
{renderUndoButton()}

View File

@@ -1,11 +1,10 @@
import * as React from 'react';
import TextInput from './TextInput';
import { View, StyleSheet, TextInputProps, ViewStyle, TextInput as ReactNativeTextInput } from 'react-native';
import { View, StyleSheet, TextInputProps, ViewStyle, TextInput as ReactNativeTextInput, Keyboard } from 'react-native';
import { _ } from '@joplin/lib/locale';
import { Ref, useCallback, useMemo } from 'react';
import { themeStyle } from './global-style';
import IconButton from './IconButton';
import Icon from './Icon';
interface SearchInputProps extends TextInputProps {
@@ -58,11 +57,12 @@ const SearchInput: React.FC<SearchInputProps> = ({ inputRef, themeId, value, con
}, [onChangeText]);
return <View style={[styles.root, containerStyle]}>
<Icon
aria-hidden={true}
name='material magnify'
accessibilityLabel={null}
style={styles.icon}
<IconButton
iconName='material magnify'
onPress={() => Keyboard.dismiss()}
description={_('Hide keyboard')}
iconStyle={styles.icon}
themeId={themeId}
/>
<TextInput
ref={inputRef}

View File

@@ -38,11 +38,13 @@ const useStyles = (themeId: number, headerStyle: TextStyle|undefined) => {
color: theme.color3,
flexDirection: 'row',
alignItems: 'center',
maxWidth: '100%',
gap: 4,
},
tagText: {
color: theme.color3,
fontSize: theme.fontSize,
flexShrink: 1,
},
removeTagButton: {
color: theme.color3,
@@ -51,7 +53,7 @@ const useStyles = (themeId: number, headerStyle: TextStyle|undefined) => {
},
tagBoxRoot: {
flexDirection: 'column',
flexGrow: 1,
flexGrow: 0.5,
flexShrink: 1,
},
tagBoxScrollView: {
@@ -122,7 +124,11 @@ const TagCard: React.FC<TagChipProps> = props => {
style={props.styles.tag}
role='listitem'
>
<Text style={props.styles.tagText}>{props.title}</Text>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={props.styles.tagText}
>{props.title}</Text>
<IconButton
pressableRef={removeButtonRef}
themeId={props.themeId}
@@ -171,6 +177,7 @@ const TagsBox: React.FC<TagsBoxProps> = props => {
return <View style={props.styles.tagBoxRoot}>
<Text style={props.styles.header} role='heading'>{_('Associated tags:')}</Text>
<ScrollView
keyboardShouldPersistTaps="handled"
style={props.styles.tagBoxScrollView}
// On web, specifying aria-live here announces changes to the associated tags.
// However, on Android (and possibly iOS), this breaks focus behavior:

View File

@@ -8,6 +8,7 @@ import { themeStyle } from './global-style';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import useKeyboardState from '../utils/hooks/useKeyboardState';
import usePrevious from '@joplin/lib/hooks/usePrevious';
import FeedbackBanner from './FeedbackBanner';
interface Props {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -67,6 +68,7 @@ const AppNavComponent: React.FC<Props> = (props) => {
<NotesScreen visible={notesScreenVisible} />
{searchScreenLoaded && <SearchScreen visible={searchScreenVisible} />}
{!notesScreenVisible && !searchScreenVisible && <Screen navigation={{ state: route }} themeId={props.themeId} dispatch={props.dispatch} />}
{notesScreenVisible ? <FeedbackBanner/> : null}
<View style={{ height: autocompletionBarPadding }} />
</KeyboardAvoidingView>
);

View File

@@ -6,11 +6,12 @@ import createMockReduxStore from '../../utils/testing/createMockReduxStore';
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
import PluginRunnerWebView from './PluginRunnerWebView';
import TestProviderStack from '../testing/TestProviderStack';
import { render, waitFor } from '../../utils/testing/testingLibrary';
import { act, render, screen, waitFor } from '../../utils/testing/testingLibrary';
import createTestPlugin from '@joplin/lib/testing/plugins/createTestPlugin';
import getWebViewDomById from '../../utils/testing/getWebViewDomById';
import Setting from '@joplin/lib/models/Setting';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import CommandService from '@joplin/lib/services/CommandService';
let store: Store<AppState>;
@@ -30,6 +31,16 @@ const defaultManifestProperties = {
name: 'Some plugin name',
};
type PluginSlice = { manifest: { id: string } };
const waitForPluginToLoad = (plugin: PluginSlice) => {
return waitFor(async () => {
expect(PluginService.instance().pluginById(plugin.manifest.id)).toBeTruthy();
});
};
const webViewId = 'joplin__PluginDialogWebView';
const getUserWebViewDom = () => getWebViewDomById(webViewId);
describe('PluginRunnerWebView', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
@@ -56,16 +67,68 @@ describe('PluginRunnerWebView', () => {
`,
});
render(<WrappedPluginRunnerWebView/>);
// Should load the plugin
await waitFor(async () => {
expect(PluginService.instance().pluginById(testPlugin.manifest.id)).toBeTruthy();
});
await waitForPluginToLoad(testPlugin);
// Should show the dialog
await waitFor(async () => {
const dom = await getWebViewDomById('joplin__PluginDialogWebView');
const dom = await getUserWebViewDom();
expect(dom.querySelector('h1').textContent).toBe('Test!');
});
});
test('should load a plugin that adds a panel', async () => {
const testPlugin = await createTestPlugin({
...defaultManifestProperties,
id: 'org.joplinapp.panel-test',
}, {
onStart: `
const panels = joplin.views.panels;
const handle = await panels.create('test-panel');
await panels.setHtml(
handle,
'<h1>Panel content</h1><p>Test</p>',
);
const commands = joplin.commands;
await commands.register({
name: 'hideTestPanel',
label: 'Hide the test plugin panel',
execute: async () => {
await panels.hide(handle);
},
});
await commands.register({
name: 'showTestPanel',
execute: async () => {
await panels.show(handle);
},
});
`,
});
render(<WrappedPluginRunnerWebView/>);
await waitForPluginToLoad(testPlugin);
act(() => {
store.dispatch({ type: 'SET_PLUGIN_PANELS_DIALOG_VISIBLE', visible: true });
});
const expectPanelVisible = async () => {
const dom = await getUserWebViewDom();
await waitFor(async () => {
expect(dom.querySelector('h1').textContent).toBe('Panel content');
});
};
await expectPanelVisible();
// Should hide the panel
await act(() => CommandService.instance().execute('hideTestPanel'));
await waitFor(() => {
expect(screen.queryByTestId('webViewId')).toBeNull();
});
// Should show the panel again
await act(() => CommandService.instance().execute('showTestPanel'));
await expectPanelVisible();
});
});

View File

@@ -120,7 +120,7 @@ const PluginPanelViewer: React.FC<Props> = props => {
}
return (
<View style={styles.webViewContainer}>
<View style={styles.webViewContainer} testID='plugin-tab-content'>
<PluginUserWebView
key={selectedTabId}
themeId={props.themeId}

View File

@@ -1,7 +1,6 @@
import * as React from 'react';
import { ConfigScreenStyles } from '../configScreenStyles';
import Icon from '../../../Icon';
import BetaChip from '../../../BetaChip';
import { TouchableRipple, Text } from 'react-native-paper';
import { View } from 'react-native';
import Setting, { AppType, SettingMetadataSection } from '@joplin/lib/models/Setting';
@@ -21,9 +20,6 @@ const SectionTab: React.FC<Props> = ({ styles, onPress, selected, section }) =>
const styleSheet = styles.styleSheet;
const titleStyle = selected ? styleSheet.sidebarSelectedButtonText : styleSheet.sidebarButtonMainText;
const isBeta = section.name === 'plugins';
const betaChip = isBeta ? <BetaChip size={10}/> : null;
return (
<TouchableRipple
key={section.name}
@@ -47,8 +43,6 @@ const SectionTab: React.FC<Props> = ({ styles, onPress, selected, section }) =>
>
{label}
</Text>
{betaChip}
</View>
<Text
style={styleSheet.sidebarButtonDescriptionText}

View File

@@ -4,7 +4,6 @@ import { createTempDir, mockMobilePlatform, setupDatabaseAndSynchronizer, switch
import { act, fireEvent, render, screen, userEvent, waitFor } from '../../../../utils/testing/testingLibrary';
import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService';
import pluginServiceSetup from './testUtils/pluginServiceSetup';
import { writeFile } from 'fs-extra';
import { join } from 'path';
import shim from '@joplin/lib/shim';
@@ -15,6 +14,7 @@ import createMockReduxStore from '../../../../utils/testing/createMockReduxStore
import WrappedPluginStates from './testUtils/WrappedPluginStates';
import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor';
import Setting from '@joplin/lib/models/Setting';
import mockPluginServiceSetup from '../../../../utils/testing/mockPluginServiceSetup';
let reduxStore: Store<AppState> = null;
@@ -56,7 +56,7 @@ describe('PluginStates.installed', () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
reduxStore = createMockReduxStore();
pluginServiceSetup(reduxStore);
mockPluginServiceSetup(reduxStore);
resetRepoApi();
await mockMobilePlatform('android');

View File

@@ -3,13 +3,13 @@ import { mockMobilePlatform, setupDatabaseAndSynchronizer, switchClient } from '
import { render, screen, userEvent, waitFor } from '../../../../utils/testing/testingLibrary';
import pluginServiceSetup from './testUtils/pluginServiceSetup';
import createMockReduxStore from '../../../../utils/testing/createMockReduxStore';
import WrappedPluginStates from './testUtils/WrappedPluginStates';
import { AppState } from '../../../../utils/types';
import { Store } from 'redux';
import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor';
import { resetRepoApi } from './utils/useRepoApi';
import mockPluginServiceSetup from '../../../../utils/testing/mockPluginServiceSetup';
const expectSearchResultCountToBe = async (count: number) => {
await waitFor(() => {
@@ -37,7 +37,7 @@ describe('PluginStates.search', () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
reduxStore = createMockReduxStore();
pluginServiceSetup(reduxStore);
mockPluginServiceSetup(reduxStore);
mockMobilePlatform('android');
resetRepoApi();

View File

@@ -12,7 +12,6 @@ import useRepoApi from './utils/useRepoApi';
import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi';
import PluginInfoModal from './PluginInfoModal';
import usePluginCallbacks from './utils/usePluginCallbacks';
import BetaChip from '../../../BetaChip';
import SectionLabel from './SectionLabel';
interface Props {
@@ -191,10 +190,6 @@ const PluginStates: React.FC<Props> = props => {
return (
<View>
{renderRepoApiStatus()}
<Banner visible={true} elevation={0} icon={() => <BetaChip size={13}/>}>
<Text>Plugin support on mobile is still in beta. Plugins may cause performance issues. Some have only partial support for Joplin mobile.</Text>
</Banner>
<Divider/>
{showSearch ? searchSection : null}
<View style={styles.installedPluginsContainer}>

View File

@@ -0,0 +1,14 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
const PluginService_1 = require('@joplin/lib/services/plugins/PluginService');
const BasePluginRunner_1 = require('@joplin/lib/services/plugins/BasePluginRunner');
class MockPluginRunner extends BasePluginRunner_1.default {
async run() { }
async stop() { }
}
const pluginServiceSetup = (store) => {
const runner = new MockPluginRunner();
PluginService_1.default.instance().initialize('2.14.0', { joplin: {} }, runner, store);
};
exports.default = pluginServiceSetup;
// # sourceMappingURL=pluginServiceSetup.js.map

View File

@@ -17,6 +17,13 @@ interface Props {
tags: TagEntity[];
}
const modalPropOverrides = {
scrollOverflow: {
// Prevent the keyboard from auto-dismissing when tapping outside the search input
keyboardShouldPersistTaps: true,
},
};
const NoteTagsDialogComponent: React.FC<Props> = props => {
const [noteId, setNoteId] = useState(props.noteId);
const [savingTags, setSavingTags] = useState(false);
@@ -57,6 +64,7 @@ const NoteTagsDialogComponent: React.FC<Props> = props => {
buttonBarEnabled={!savingTags}
okTitle={_('Apply')}
cancelTitle={_('Cancel')}
modalProps={modalPropOverrides}
>
<TagEditor
themeId={props.themeId}
@@ -64,6 +72,7 @@ const NoteTagsDialogComponent: React.FC<Props> = props => {
allTags={props.tags}
onTagsChange={setNoteTags}
mode={TagEditorMode.Large}
searchResultProps={{ nestedScrollEnabled: true }}
style={{ flex: 1 }}
/>
</ModalDialog>;

View File

@@ -1,30 +1,62 @@
import { createEditor } from '@joplin/editor/CodeMirror';
import { focus } from '@joplin/lib/utils/focusHandler';
import WebViewToRNMessenger from '../../utils/ipc/WebViewToRNMessenger';
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
import { EditorProcessApi, EditorProps, EditorWithParentProps, ExportedWebViewGlobals, MainProcessApi } from './types';
import readFileToBase64 from '../utils/readFileToBase64';
import { EditorControl } from '@joplin/editor/types';
import { EditorEventType } from '@joplin/editor/events';
import InMemoryCache from '@joplin/renderer/InMemoryCache';
export { default as setUpLogger } from '../utils/setUpLogger';
export const initializeEditor = ({
parentElementClassName,
interface ExtendedWindow extends ExportedWebViewGlobals, Window { }
declare const window: ExtendedWindow;
let mainEditor: EditorControl|null = null;
let allEditors: EditorControl[] = [];
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', {
get mainEditor() {
return mainEditor;
},
updatePlugins(contentScripts) {
for (const editor of allEditors) {
void editor.setContentScripts(contentScripts);
}
},
updateSettings(settings) {
for (const editor of allEditors) {
editor.updateSettings(settings);
}
},
});
export const createEditorWithParent = ({
parentElementOrClassName,
initialText,
initialNoteId,
settings,
onLocalize,
}: EditorProps) => {
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', null);
const parentElement = document.getElementsByClassName(parentElementClassName)[0] as HTMLElement;
onEvent,
}: EditorWithParentProps) => {
const parentElement = (() => {
if (parentElementOrClassName instanceof HTMLElement) {
return parentElementOrClassName;
}
return document.getElementsByClassName(parentElementOrClassName)[0] as HTMLElement;
})();
if (!parentElement) {
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementClassName)})`);
throw new Error(`Unable to find parent element for editor (class name: ${JSON.stringify(parentElementOrClassName)})`);
}
// resolveImageSrc can be called frequently for the same image. To avoid unnecessary IPC,
// use an InMemoryCache.
const resolvedImageSrcCache = new InMemoryCache();
const control = createEditor(parentElement, {
initialText,
initialNoteId,
settings,
onLocalize,
onLocalize: messenger.remoteApi.onLocalize,
onPasteFile: async (data) => {
const base64 = await readFileToBase64(data);
@@ -34,11 +66,37 @@ export const initializeEditor = ({
onLogMessage: message => {
void messenger.remoteApi.logMessage(message);
},
onEvent: (event): void => {
void messenger.remoteApi.onEditorEvent(event);
onEvent: (event) => {
onEvent(event);
if (event.kind === EditorEventType.Remove) {
allEditors = allEditors.filter(other => other !== control);
}
},
resolveImageSrc: (src) => {
return messenger.remoteApi.onResolveImageSrc(src);
resolveImageSrc: async (src, reloadCounter) => {
const cacheKey = `cachedImage.${reloadCounter}.${src}`;
const cachedValue = resolvedImageSrcCache.value(cacheKey);
if (cachedValue) {
return cachedValue;
}
const result = messenger.remoteApi.onResolveImageSrc(src, reloadCounter);
resolvedImageSrcCache.setValue(cacheKey, result);
return result;
},
});
allEditors.push(control);
void messenger.remoteApi.onEditorAdded();
return control;
};
export const createMainEditor = (props: EditorProps) => {
const control = createEditorWithParent({
...props,
onEvent: (event) => {
void messenger.remoteApi.onEditorEvent(event);
},
});
@@ -57,6 +115,7 @@ export const initializeEditor = ({
// Note: Just adding an onclick listener seems sufficient to focus the editor when its background
// is tapped.
const parentElement = control.editor.dom.parentElement;
parentElement.addEventListener('click', (event) => {
const activeElement = document.querySelector(':focus');
if (!parentElement.contains(activeElement) && event.target === parentElement) {
@@ -64,8 +123,9 @@ export const initializeEditor = ({
}
});
messenger.setLocalInterface({
editor: control,
});
mainEditor = control;
return control;
};
window.createEditorWithParent = createEditorWithParent;
window.createMainEditor = createMainEditor;

View File

@@ -1,8 +1,29 @@
import { EditorEvent } from '@joplin/editor/events';
import { EditorControl, EditorSettings, OnLocalize } from '@joplin/editor/types';
import { ContentScriptData, EditorControl, EditorSettings, LocalizationResult } from '@joplin/editor/types';
export interface EditorProps {
parentElementOrClassName: HTMLElement|string;
initialText: string;
initialNoteId: string|null;
settings: EditorSettings;
}
export interface EditorWithParentProps extends EditorProps {
onEvent: (editorEvent: EditorEvent)=> void;
}
// The Markdown editor exposes global functions within its <WebView>.
// These functions can be used externally.
export interface ExportedWebViewGlobals {
createEditorWithParent: (options: EditorWithParentProps)=> EditorControl;
createMainEditor: (props: EditorProps)=> EditorControl;
}
export interface EditorProcessApi {
editor: EditorControl;
mainEditor: EditorControl;
updateSettings: (settings: EditorSettings)=> void;
updatePlugins: (contentScripts: ContentScriptData[])=> void;
}
export interface SelectionRange {
@@ -10,17 +31,11 @@ export interface SelectionRange {
end: number;
}
export interface EditorProps {
parentElementClassName: string;
initialText: string;
initialNoteId: string;
onLocalize: OnLocalize;
settings: EditorSettings;
}
export interface MainProcessApi {
onLocalize(text: string): LocalizationResult;
onEditorEvent(event: EditorEvent): Promise<void>;
onEditorAdded(): Promise<void>;
logMessage(message: string): Promise<void>;
onPasteFile(type: string, dataBase64: string): Promise<void>;
onResolveImageSrc(src: string): Promise<string|null>;
onResolveImageSrc(src: string, reloadCounter: number): Promise<string|null>;
}

View File

@@ -7,6 +7,9 @@ import { OnMessageEvent, WebViewControl } from '../../components/ExtendedWebView
import { EditorEvent } from '@joplin/editor/events';
import Logger from '@joplin/utils/Logger';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import { _ } from '@joplin/lib/locale';
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import useCodeMirrorPlugins from './utils/useCodeMirrorPlugins';
import Resource from '@joplin/lib/models/Resource';
import { parseResourceUrl } from '@joplin/lib/urlUtils';
const { isImageMimeType } = require('@joplin/lib/resourceUtils');
@@ -15,9 +18,10 @@ const logger = Logger.create('markdownEditor');
interface Props {
editorOptions: EditorOptions;
initialSelection: SelectionRange;
initialSelection: SelectionRange|null;
noteHash: string;
globalSearch: string;
pluginStates: PluginStates;
onEditorEvent: (event: EditorEvent)=> void;
onAttachFile: (mime: string, base64: string)=> void;
@@ -33,9 +37,11 @@ const defaultSearchState: SearchState = {
dialogVisible: false,
};
type Result = SetUpResult<EditorProcessApi> & { hasPlugins: boolean };
const useWebViewSetup = ({
editorOptions, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile,
}: Props): SetUpResult<EditorProcessApi> => {
editorOptions, pluginStates, initialSelection, noteHash, globalSearch, webviewRef, onEditorEvent, onAttachFile,
}: Props): Result => {
const setInitialSelectionJs = initialSelection ? `
cm.select(${initialSelection.start}, ${initialSelection.end});
cm.execCommand('scrollSelectionIntoView');
@@ -51,20 +57,21 @@ const useWebViewSetup = ({
` : '';
const injectedJavaScript = useMemo(() => `
if (typeof markdownEditorBundle === 'undefined') {
${shim.injectedJs('markdownEditorBundle')};
window.markdownEditorBundle = markdownEditorBundle;
markdownEditorBundle.setUpLogger();
}
if (!window.cm) {
const parentClassName = ${JSON.stringify(editorOptions.parentElementClassName)};
const foundParent = document.getElementsByClassName(parentClassName).length > 0;
const parentClassName = ${JSON.stringify(editorOptions?.parentElementOrClassName)};
const foundParent = !!parentClassName && document.getElementsByClassName(parentClassName).length > 0;
// On Android, injectedJavaScript can be run multiple times, including once before the
// document has loaded. To avoid logging an error each time the editor starts, don't throw
// if the parent element can't be found:
if (foundParent) {
${shim.injectedJs('markdownEditorBundle')};
markdownEditorBundle.setUpLogger();
window.cm = markdownEditorBundle.initializeEditor(
${JSON.stringify(editorOptions)}
);
window.cm = markdownEditorBundle.createMainEditor(${JSON.stringify(editorOptions)});
${jumpToHashJs}
// Set the initial selection after jumping to the header -- the initial selection,
@@ -75,7 +82,7 @@ const useWebViewSetup = ({
window.onresize = () => {
cm.execCommand('scrollSelectionIntoView');
};
} else {
} else if (parentClassName) {
console.log('No parent element found with class name ', parentClassName);
}
}
@@ -101,6 +108,10 @@ const useWebViewSetup = ({
const onAttachRef = useRef(onAttachFile);
onAttachRef.current = onAttachFile;
const codeMirrorPlugins = useCodeMirrorPlugins(pluginStates);
const codeMirrorPluginsRef = useRef(codeMirrorPlugins);
codeMirrorPluginsRef.current = codeMirrorPlugins;
const editorMessenger = useMemo(() => {
const localApi: MainProcessApi = {
async onEditorEvent(event) {
@@ -112,7 +123,14 @@ const useWebViewSetup = ({
async onPasteFile(type, data) {
onAttachRef.current(type, data);
},
async onResolveImageSrc(src) {
async onLocalize(text) {
const localizationFunction = _;
return localizationFunction(text);
},
async onEditorAdded() {
messenger.remoteApi.updatePlugins(codeMirrorPluginsRef.current);
},
async onResolveImageSrc(src, reloadCounter) {
const url = parseResourceUrl(src);
if (!url.itemId) return null;
const item = await Resource.load(url.itemId);
@@ -126,7 +144,8 @@ const useWebViewSetup = ({
}
return null;
} else {
return Resource.fullPath(item);
const path = Resource.fullPath(item);
return reloadCounter ? `${path}?r=${reloadCounter}` : path;
}
},
};
@@ -153,17 +172,22 @@ const useWebViewSetup = ({
const editorSettings = editorOptions.settings;
useEffect(() => {
api.editor.updateSettings(editorSettings);
api.updateSettings(editorSettings);
}, [api, editorSettings]);
useEffect(() => {
api.updatePlugins(codeMirrorPlugins);
}, [codeMirrorPlugins, api]);
return useMemo(() => ({
pageSetup: {
js: injectedJavaScript,
css: '',
},
hasPlugins: codeMirrorPlugins.length > 0,
api,
webViewEventHandlers,
}), [injectedJavaScript, api, webViewEventHandlers]);
}), [injectedJavaScript, api, webViewEventHandlers, codeMirrorPlugins]);
};
export default useWebViewSetup;

View File

@@ -17,6 +17,7 @@ import Resource from '@joplin/lib/models/Resource';
import { ResourceInfos } from '@joplin/renderer/types';
import useContentScripts from './utils/useContentScripts';
import uuid from '@joplin/lib/uuid';
import AsyncActionQueue from '@joplin/lib/AsyncActionQueue';
const logger = Logger.create('renderer/useWebViewSetup');
@@ -149,6 +150,8 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
void messenger.remoteApi.renderer.setExtraContentScriptsAndRerender(contentScripts);
}, [messenger, contentScripts]);
const onRerenderRequestRef = useRef(()=>{});
const rendererControl = useMemo((): RendererControl => {
const renderer = messenger.remoteApi.renderer;
@@ -185,7 +188,7 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
};
let settingsChanged = false;
const settings: RenderSettings = {
const getSettings = (): RenderSettings => ({
...options,
codeTheme: theme.codeThemeCss,
// We .stringify the theme to avoid a JSON serialization error involving
@@ -201,6 +204,7 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
const key = `${pluginId}.${settingKey}`;
if (!pluginSettingKeysRef.current.has(key)) {
pluginSettingKeysRef.current.add(key);
onRerenderRequestRef.current();
settingsChanged = true;
}
},
@@ -220,12 +224,12 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
return shim.fsDriver().fileAtPath(resolvedPath);
},
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
};
});
await transferResources(options.resources);
return {
settings,
getSettings,
getSettingsChanged() {
return settingsChanged;
},
@@ -234,23 +238,28 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
return {
rerenderToBody: async (markup, options, cancelEvent) => {
const { settings, getSettingsChanged } = await prepareRenderer(options);
const { getSettings } = await prepareRenderer(options);
if (cancelEvent?.cancelled) return null;
const output = await renderer.rerenderToBody(markup, settings);
if (cancelEvent?.cancelled) return null;
const render = async () => {
if (cancelEvent?.cancelled) return;
if (getSettingsChanged()) {
return await renderer.rerenderToBody(markup, settings);
}
return output;
await renderer.rerenderToBody(markup, getSettings());
};
const queue = new AsyncActionQueue();
onRerenderRequestRef.current = async () => {
queue.push(render);
};
return await render();
},
render: async (markup, options) => {
const { settings, getSettingsChanged } = await prepareRenderer(options);
const output = await renderer.render(markup, settings);
const { getSettings, getSettingsChanged } = await prepareRenderer(options);
const output = await renderer.render(markup, getSettings());
if (getSettingsChanged()) {
return await renderer.render(markup, settings);
return await renderer.render(markup, getSettings());
}
return output;
},

View File

@@ -7,17 +7,8 @@ import '@joplin/editor/ProseMirror/styles';
import readFileToBase64 from '../../utils/readFileToBase64';
import { EditorLanguageType } from '@joplin/editor/types';
import convertHtmlToMarkdown from './convertHtmlToMarkdown';
const postprocessHtml = (html: HTMLElement) => {
// Fix resource URLs
const resources = html.querySelectorAll<HTMLImageElement>('img[data-resource-id]');
for (const resource of resources) {
const resourceId = resource.getAttribute('data-resource-id');
resource.src = `:/${resourceId}`;
}
return html;
};
import { ExportedWebViewGlobals as MarkdownEditorWebViewGlobals } from '../../markdownEditorBundle/types';
import { EditorEventType } from '@joplin/editor/events';
const wrapHtmlForMarkdownConversion = (html: HTMLElement) => {
// Add a container element -- when converting to HTML, Turndown
@@ -30,18 +21,19 @@ const wrapHtmlForMarkdownConversion = (html: HTMLElement) => {
const htmlToMarkdown = (html: HTMLElement): string => {
html = postprocessHtml(html);
return convertHtmlToMarkdown(html);
};
export const initialize = async ({
settings,
initialText,
initialNoteId,
parentElementClassName,
initialSearch,
}: EditorProps) => {
export const initialize = async (
{
settings,
initialText,
initialNoteId,
parentElementClassName,
initialSearch,
}: EditorProps,
markdownEditorApi: MarkdownEditorWebViewGlobals,
) => {
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('rich-text-editor', null);
const parentElement = document.getElementsByClassName(parentElementClassName)[0];
if (!parentElement) throw new Error('Parent element not found');
@@ -86,29 +78,25 @@ export const initialize = async ({
removeUnusedPluginAssets: options.isFullPageRender,
});
},
renderHtmlToMarkup: (node) => {
// By default, if `src` is specified on an image, the browser will try to load the image, even if it isn't added
// to the DOM. (A similar problem is described here: https://stackoverflow.com/q/62019538).
// Since :/resourceId isn't a valid image URI, this results in a large number of warnings. As a workaround,
// move the element to a temporary document before processing:
const dom = document.implementation.createHTMLDocument();
node = dom.importNode(node, true);
let html: HTMLElement;
if ((node instanceof HTMLElement)) {
html = node;
} else {
const container = document.createElement('div');
container.appendChild(html);
html = container;
}
renderHtmlToMarkup: (html) => {
if (settings.language === EditorLanguageType.Markdown) {
return htmlToMarkdown(wrapHtmlForMarkdownConversion(html));
} else {
return postprocessHtml(html).outerHTML;
return html.outerHTML;
}
},
}, (parent, language, onChange) => {
return markdownEditorApi.createEditorWithParent({
initialText: '',
initialNoteId: '',
parentElementOrClassName: parent,
settings: { ...editor.getSettings(), language },
onEvent: (event) => {
if (event.kind === EditorEventType.Change) {
onChange(event.value);
}
},
});
});
editor.setSearchState(initialSearch);

View File

@@ -4,6 +4,7 @@ import { SetUpResult } from '../types';
import { EditorControl, EditorSettings } from '@joplin/editor/types';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
import { EditorProcessApi, EditorProps, MainProcessApi } from './types';
import useMarkdownEditorSetup from '../markdownEditorBundle/useWebViewSetup';
import useRendererSetup from '../rendererBundle/useWebViewSetup';
import { EditorEvent } from '@joplin/editor/events';
import Logger from '@joplin/utils/Logger';
@@ -92,7 +93,10 @@ const useMessenger = (props: UseMessengerProps) => {
}, [props.webviewRef]);
};
type UseSourceProps = Props & { renderer: SetUpResult<RendererControl> };
type UseSourceProps = Props & {
renderer: SetUpResult<RendererControl>;
markdownEditor: SetUpResult<unknown>;
};
const useSource = (props: UseSourceProps) => {
const propsRef = useRef(props);
@@ -100,6 +104,8 @@ const useSource = (props: UseSourceProps) => {
const rendererJs = props.renderer.pageSetup.js;
const rendererCss = props.renderer.pageSetup.css;
const markdownEditorJs = props.markdownEditor.pageSetup.js;
const markdownEditorCss = props.markdownEditor.pageSetup.css;
return useMemo(() => {
const editorOptions: EditorProps = {
@@ -117,6 +123,7 @@ const useSource = (props: UseSourceProps) => {
css: `
${shim.injectedCss('richTextEditorBundle')}
${rendererCss}
${markdownEditorCss}
/* Increase the size of the editor to make it easier to focus the editor. */
.prosemirror-editor {
@@ -125,19 +132,23 @@ const useSource = (props: UseSourceProps) => {
`,
js: `
${rendererJs}
${markdownEditorJs}
if (!window.richTextEditorCreated) {
window.richTextEditorCreated = true;
${shim.injectedJs('richTextEditorBundle')}
richTextEditorBundle.setUpLogger();
richTextEditorBundle.initialize(${JSON.stringify(editorOptions)}).then(function(editor) {
richTextEditorBundle.initialize(
${JSON.stringify(editorOptions)},
window,
).then(function(editor) {
/* For testing */
window.joplinRichTextEditor_ = editor;
});
}
`,
};
}, [rendererJs, rendererCss]);
}, [rendererJs, rendererCss, markdownEditorCss, markdownEditorJs]);
};
const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
@@ -148,8 +159,23 @@ const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
pluginStates: props.pluginStates,
themeId: props.themeId,
});
const markdownEditor = useMarkdownEditorSetup({
webviewRef: props.webviewRef,
onAttachFile: props.onAttachFile,
initialSelection: null,
noteHash: '',
globalSearch: props.globalSearch,
editorOptions: {
settings: props.settings,
initialNoteId: null,
parentElementOrClassName: '',
initialText: '',
},
onEditorEvent: (_event)=>{},
pluginStates: props.pluginStates,
});
const messenger = useMessenger({ ...props, renderer });
const pageSetup = useSource({ ...props, renderer });
const pageSetup = useSource({ ...props, renderer, markdownEditor });
useEffect(() => {
void messenger.remoteApi.editor.updateSettings(props.settings);
@@ -163,14 +189,16 @@ const useWebViewSetup = (props: Props): SetUpResult<EditorControl> => {
onLoadEnd: () => {
messenger.onWebViewLoaded();
renderer.webViewEventHandlers.onLoadEnd();
markdownEditor.webViewEventHandlers.onLoadEnd();
},
onMessage: (event) => {
messenger.onWebViewMessage(event);
renderer.webViewEventHandlers.onMessage(event);
markdownEditor.webViewEventHandlers.onMessage(event);
},
},
};
}, [messenger, pageSetup, renderer.webViewEventHandlers]);
}, [messenger, pageSetup, renderer.webViewEventHandlers, markdownEditor.webViewEventHandlers]);
};
export default useWebViewSetup;

View File

@@ -1,3 +1,4 @@
import './utils/polyfills';
import { AppRegistry } from 'react-native';
import Root from './root';
import Setting from '@joplin/lib/models/Setting';

View File

@@ -363,6 +363,7 @@
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/react-native-image-picker/RNImagePickerPrivacyInfo.bundle",
);
name = "[CP] Copy Pods Resources";
@@ -394,6 +395,7 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNImagePickerPrivacyInfo.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
@@ -533,7 +535,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 143;
CURRENT_PROJECT_VERSION = 145;
DEVELOPMENT_TEAM = A9BXAFS6CT;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Joplin/Info.plist;
@@ -542,7 +544,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.4.1;
MARKETING_VERSION = 13.4.3;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -568,7 +570,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Joplin/Joplin.entitlements;
CURRENT_PROJECT_VERSION = 143;
CURRENT_PROJECT_VERSION = 145;
DEVELOPMENT_TEAM = A9BXAFS6CT;
INFOPLIST_FILE = Joplin/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.6;
@@ -576,7 +578,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 13.4.1;
MARKETING_VERSION = 13.4.3;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -769,7 +771,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 143;
CURRENT_PROJECT_VERSION = 145;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -780,7 +782,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.4.1;
MARKETING_VERSION = 13.4.3;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
@@ -812,7 +814,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 143;
CURRENT_PROJECT_VERSION = 145;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = A9BXAFS6CT;
GCC_C_LANGUAGE_STANDARD = gnu11;
@@ -823,7 +825,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 13.4.1;
MARKETING_VERSION = 13.4.3;
MTL_FAST_MATH = YES;
OTHER_LDFLAGS = (
"$(inherited)",

View File

@@ -1458,7 +1458,7 @@ PODS:
- Yoga
- react-native-get-random-values (1.11.0):
- React-Core
- react-native-image-picker (7.2.3):
- react-native-image-picker (8.0.0):
- DoubleConversion
- glog
- hermes-engine
@@ -1514,9 +1514,9 @@ PODS:
- Yoga
- react-native-rsa-native (2.0.5):
- React
- react-native-saf-x (3.4.0):
- react-native-saf-x (3.4.1):
- React-Core
- react-native-safe-area-context (5.4.0):
- react-native-safe-area-context (5.4.1):
- React-Core
- react-native-sqlite-storage (6.0.1):
- React-Core
@@ -2285,7 +2285,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90
DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb
EXAV: ae28256069c4cdde93d185c007d8f68d92902c2e
EXConstants: 98bcf0f22b820f9b28f9fee55ff2daededadd2f8
Expo: 4b1c6de7c441e1caa1918671ae0aa34d51f019a5
@@ -2298,7 +2298,7 @@ SPEC CHECKSUMS:
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 84b955f7b4da8b895faf5946f73748267347c975
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
hermes-engine: 314be5250afa5692b57b4dd1705959e1973a8ebe
JoplinCommonShareExtension: a8b60b02704d85a7305627912c0240e94af78db7
JoplinRNShareExtension: e158a4b53ee0aa9cd3037a16221dc8adbd6f7860
@@ -2338,13 +2338,13 @@ SPEC CHECKSUMS:
react-native-document-picker: da39c5e4f279d39c0356dca157b98f9dc349e5bb
react-native-geolocation: ec15ffebc53790314885eb9e5f2132132fbc2600
react-native-get-random-values: d16467cf726c618e9c7a8c3c39c31faa2244bbba
react-native-image-picker: 99fbcec11cf4679170a7cfba4e4d9f598297448c
react-native-image-picker: 922b9ba90f144b5866d07d04b0fb2b4e9ab0ed75
react-native-image-resizer: 24c5d06fae2176dc0caed4b6396e02befb44064a
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
react-native-quick-crypto: 988d8d57cd720dbe218272b60775a8e0210d0b80
react-native-rsa-native: a7931cdda1f73a8576a46d7f431378c5550f0c38
react-native-saf-x: 24ebe9aa153f82ec6726de459ae77508d68d5599
react-native-safe-area-context: 9d72abf6d8473da73033b597090a80b709c0b2f1
react-native-saf-x: 3f8b52fb8160d7322161dec02a564271cc8f4138
react-native-safe-area-context: dde2052b903c11d677c320b599c3244021c34ce8
react-native-sqlite-storage: 0c84826214baaa498796c7e46a5ccc9a82e114ed
react-native-version-info: f0b04e16111c4016749235ff6d9a757039189141
react-native-webview: 1b5778b306d4ed09d13829a6e7a6550e3c1a644a

View File

@@ -58,15 +58,15 @@
"react-native-file-viewer": "2.1.5",
"react-native-fs": "2.20.0",
"react-native-get-random-values": "1.11.0",
"react-native-image-picker": "7.2.3",
"react-native-image-picker": "8.0.0",
"react-native-localize": "3.4.1",
"react-native-modal-datetime-picker": "18.0.0",
"react-native-paper": "5.13.5",
"react-native-popup-menu": "0.17.0",
"react-native-quick-actions": "0.3.13",
"react-native-quick-crypto": "0.7.13",
"react-native-quick-crypto": "0.7.17",
"react-native-rsa-native": "2.0.5",
"react-native-safe-area-context": "5.4.0",
"react-native-safe-area-context": "5.4.1",
"react-native-securerandom": "1.0.1",
"react-native-share": "12.0.11",
"react-native-sqlite-storage": "6.0.1",
@@ -106,7 +106,7 @@
"@testing-library/react-native": "13.2.0",
"@types/fs-extra": "11.0.4",
"@types/jest": "29.5.14",
"@types/node": "18.19.101",
"@types/node": "18.19.103",
"@types/react": "19.0.14",
"@types/react-redux": "7.1.33",
"@types/serviceworker": "0.0.135",
@@ -115,7 +115,7 @@
"babel-loader": "9.1.3",
"babel-plugin-module-resolver": "4.1.0",
"babel-plugin-react-native-web": "0.20.0",
"esbuild": "0.25.4",
"esbuild": "0.25.5",
"fast-deep-equal": "3.1.3",
"fs-extra": "11.2.0",
"gulp": "4.0.2",
@@ -130,7 +130,7 @@
"react-native-web": "0.20.0",
"react-refresh": "0.17.0",
"react-test-renderer": "19.0.0",
"sharp": "0.34.1",
"sharp": "0.34.2",
"sqlite3": "5.1.6",
"timers-browserify": "2.0.12",
"ts-jest": "29.3.1",

View File

@@ -14,7 +14,7 @@ import NoteScreen from './components/screens/Note/Note';
import UpgradeSyncTargetScreen from './components/screens/UpgradeSyncTargetScreen';
import Setting, { } from '@joplin/lib/models/Setting';
import PoorManIntervals from '@joplin/lib/PoorManIntervals';
import reducer, { NotesParent, serializeNotesParent } from '@joplin/lib/reducer';
import { NotesParent, serializeNotesParent } from '@joplin/lib/reducer';
import ShareExtension, { UnsubscribeShareListener } from './utils/ShareExtension';
import handleShared from './utils/shareHandler';
import { _, setLocale } from '@joplin/lib/locale';
@@ -28,7 +28,6 @@ import NetInfo, { NetInfoSubscription } from '@react-native-community/netinfo';
const DropdownAlert = require('react-native-dropdownalert').default;
import SafeAreaView from './components/SafeAreaView';
const { connect, Provider } = require('react-redux');
import fastDeepEqual = require('fast-deep-equal');
import { Provider as PaperProvider, MD3DarkTheme, MD3LightTheme } from 'react-native-paper';
import BackButtonService, { BackButtonHandler } from './services/BackButtonService';
import NavService from '@joplin/lib/services/NavService';
@@ -95,7 +94,6 @@ import autodetectTheme, { onSystemColorSchemeChange } from './utils/autodetectTh
import PluginRunnerWebView from './components/plugins/PluginRunnerWebView';
import { refreshFolders, scheduleRefreshFolders } from '@joplin/lib/folders-screen-utils';
import ShareManager from './components/screens/ShareManager';
import appDefaultState from './utils/appDefaultState';
import { setDateFormat, setTimeFormat, setTimeLocale } from '@joplin/utils/time';
import DialogManager from './components/DialogManager';
import { AppState } from './utils/types';
@@ -108,6 +106,7 @@ import NoteRevisionViewer from './components/screens/NoteRevisionViewer';
import DocumentScanner from './components/screens/DocumentScanner/DocumentScanner';
import buildStartupTasks from './utils/buildStartupTasks';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import appReducer from './utils/appReducer';
const logger = Logger.create('root');
const perfLogger = PerformanceLogger.create();
@@ -235,204 +234,6 @@ const generalMiddleware = (store: any) => (next: any) => async (action: any) =>
return result;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const navHistory: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function historyCanGoBackTo(route: any) {
if (route.routeName === 'Folder') return false;
// This is an intermediate screen that acts more like a modal -- it should be skipped in the
// navigation history.
if (route.routeName === 'DocumentScanner') return false;
// There's no point going back to these screens in general and, at least in OneDrive case,
// it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...)
if (route.routeName === 'OneDriveLogin') return false;
if (route.routeName === 'DropboxLogin') return false;
return true;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const appReducer = (state = appDefaultState, action: any) => {
let newState = state;
let historyGoingBack = false;
try {
switch (action.type) {
case 'NAV_BACK':
case 'NAV_GO':
if (action.type === 'NAV_BACK') {
if (!navHistory.length) break;
const newAction = navHistory.pop();
action = newAction ? newAction : navHistory.pop();
historyGoingBack = true;
}
{
const currentRoute = state.route;
if (!historyGoingBack && historyCanGoBackTo(currentRoute)) {
const previousRoute = navHistory.length && navHistory[navHistory.length - 1];
const isDifferentRoute = !previousRoute || !fastDeepEqual(navHistory[navHistory.length - 1], currentRoute);
// Avoid multiple consecutive duplicate screens in the navigation history -- these can make
// pressing "back" seem to have no effect.
if (isDifferentRoute) {
navHistory.push(currentRoute);
}
}
if (action.clearHistory) {
navHistory.splice(0, navHistory.length);
}
newState = { ...state };
newState.selectedNoteHash = '';
if (action.routeName === 'Search') {
newState.notesParentType = 'Search';
}
if ('noteId' in action) {
newState.selectedNoteIds = action.noteId ? [action.noteId] : [];
}
if ('folderId' in action) {
newState.selectedFolderId = action.folderId;
newState.notesParentType = 'Folder';
}
if ('tagId' in action) {
newState.selectedTagId = action.tagId;
newState.notesParentType = 'Tag';
}
if ('smartFilterId' in action) {
newState.smartFilterId = action.smartFilterId;
newState.selectedSmartFilterId = action.smartFilterId;
newState.notesParentType = 'SmartFilter';
}
if ('itemType' in action) {
newState.selectedItemType = action.itemType;
}
if ('noteHash' in action) {
newState.selectedNoteHash = action.noteHash;
}
if ('sharedData' in action) {
newState.sharedData = action.sharedData;
} else {
newState.sharedData = null;
}
newState.route = action;
newState.historyCanGoBack = !!navHistory.length;
logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType);
}
break;
case 'SIDE_MENU_TOGGLE':
newState = { ...state };
newState.showSideMenu = !newState.showSideMenu;
break;
case 'SIDE_MENU_OPEN':
newState = { ...state };
newState.showSideMenu = true;
break;
case 'SIDE_MENU_CLOSE':
newState = { ...state };
newState.showSideMenu = false;
break;
case 'SET_PLUGIN_PANELS_DIALOG_VISIBLE':
newState = { ...state };
newState.showPanelsDialog = action.visible;
break;
case 'NOTE_SELECTION_TOGGLE':
{
newState = { ...state };
const noteId = action.id;
const newSelectedNoteIds = state.selectedNoteIds.slice();
const existingIndex = state.selectedNoteIds.indexOf(noteId);
if (existingIndex >= 0) {
newSelectedNoteIds.splice(existingIndex, 1);
} else {
newSelectedNoteIds.push(noteId);
}
newState.selectedNoteIds = newSelectedNoteIds;
newState.noteSelectionEnabled = !!newSelectedNoteIds.length;
}
break;
case 'NOTE_SELECTION_START':
if (!state.noteSelectionEnabled) {
newState = { ...state };
newState.noteSelectionEnabled = true;
newState.selectedNoteIds = [action.id];
}
break;
case 'NOTE_SELECTION_END':
newState = { ...state };
newState.noteSelectionEnabled = false;
newState.selectedNoteIds = [];
break;
case 'NOTE_SIDE_MENU_OPTIONS_SET':
newState = { ...state };
newState.noteSideMenuOptions = action.options;
break;
case 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED':
newState = { ...state };
newState.disableSideMenuGestures = action.disableSideMenuGestures;
break;
case 'MOBILE_DATA_WARNING_UPDATE':
newState = { ...state };
newState.isOnMobileData = action.isOnMobileData;
break;
case 'KEYBOARD_VISIBLE_CHANGE':
newState = { ...state, keyboardVisible: action.visible };
break;
case 'NOTE_EDITOR_VISIBLE_CHANGE':
newState = { ...state, noteEditorVisible: action.visible };
break;
}
} catch (error) {
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
throw error;
}
return reducer(newState, action) as AppState;
};
const store = createStore(appReducer, applyMiddleware(generalMiddleware));
storeDispatch = store.dispatch;
@@ -975,46 +776,46 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
// Wrap everything in a PaperProvider -- this allows using components from react-native-paper
return (
<FocusControl.Provider>
<PaperProvider theme={{
...paperTheme,
version: 3,
colors: {
...paperTheme.colors,
onPrimaryContainer: theme.color5,
primaryContainer: theme.backgroundColor5,
<MenuProvider
style={{ flex: 1 }}
closeButtonLabel={_('Dismiss')}
>
<PaperProvider theme={{
...paperTheme,
version: 3,
colors: {
...paperTheme.colors,
onPrimaryContainer: theme.color5,
primaryContainer: theme.backgroundColor5,
outline: theme.codeBorderColor,
outline: theme.codeBorderColor,
primary: theme.color4,
onPrimary: theme.backgroundColor4,
primary: theme.color4,
onPrimary: theme.backgroundColor4,
background: theme.backgroundColor,
background: theme.backgroundColor,
surface: theme.backgroundColor,
onSurface: theme.color,
surface: theme.backgroundColor,
onSurface: theme.color,
secondaryContainer: theme.raisedBackgroundColor,
onSecondaryContainer: theme.raisedColor,
secondaryContainer: theme.raisedBackgroundColor,
onSecondaryContainer: theme.raisedColor,
surfaceVariant: theme.backgroundColor3,
onSurfaceVariant: theme.color3,
surfaceVariant: theme.backgroundColor3,
onSurfaceVariant: theme.color3,
elevation: {
level0: 'transparent',
level1: theme.oddBackgroundColor,
level2: theme.raisedBackgroundColor,
level3: theme.raisedBackgroundColor,
level4: theme.raisedBackgroundColor,
level5: theme.raisedBackgroundColor,
elevation: {
level0: 'transparent',
level1: theme.oddBackgroundColor,
level2: theme.raisedBackgroundColor,
level3: theme.raisedBackgroundColor,
level4: theme.raisedBackgroundColor,
level5: theme.raisedBackgroundColor,
},
},
},
}}>
<DialogManager themeId={this.props.themeId}>
<StatusBar barStyle={statusBarStyle} />
<MenuProvider
style={{ flex: 1 }}
closeButtonLabel={_('Dismiss')}
>
}}>
<DialogManager themeId={this.props.themeId}>
<StatusBar barStyle={statusBarStyle} />
<SafeAreaProvider>
<FocusControl.MainAppContent style={{ flex: 1 }}>
{shouldShowMainContent ? mainContent : (
@@ -1028,9 +829,9 @@ class AppComponent extends React.Component<AppComponentProps, AppComponentState>
)}
</FocusControl.MainAppContent>
</SafeAreaProvider>
</MenuProvider>
</DialogManager>
</PaperProvider>
</DialogManager>
</PaperProvider>
</MenuProvider>
</FocusControl.Provider>
);
}

View File

@@ -7,11 +7,17 @@ import libStateToWhenClauseContext, { WhenClauseContextOptions } from '@joplin/l
import { AppState } from '../../utils/types';
const stateToWhenClauseContext = (state: AppState, options: WhenClauseContextOptions = null) => {
const markdownEditorVisible = state.noteEditorVisible && state.settings['editor.codeView'];
const richTextEditorVisible = state.noteEditorVisible && !state.settings['editor.codeView'];
return {
...libStateToWhenClauseContext(state, options),
keyboardVisible: state.keyboardVisible,
markdownEditorVisible: state.noteEditorVisible && state.settings['editor.codeView'],
richTextEditorVisible: state.noteEditorVisible && !state.settings['editor.codeView'],
// Provide both markdownEditorPaneVisible and markdownEditorVisible for compatibility
// with the desktop app.
markdownEditorPaneVisible: markdownEditorVisible,
markdownEditorVisible: markdownEditorVisible,
richTextEditorVisible: richTextEditorVisible,
};
};

View File

@@ -1,46 +1,43 @@
import { RSA } from '@joplin/lib/services/e2ee/types';
import shim from '@joplin/lib/shim';
import Logger from '@joplin/utils/Logger';
import buildRsaCryptoProvider from '@joplin/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider';
import { WebCryptoSlice } from '@joplin/lib/services/e2ee/ppk/webCrypto/WebCryptoRsa';
import { CiphertextBuffer, PublicKeyAlgorithm, PublicKeyCrypto, PublicKeyCryptoProvider } from '@joplin/lib/services/e2ee/types';
import QuickCrypto from 'react-native-quick-crypto';
const RnRSA = require('react-native-rsa-native').RSA;
interface RSAKeyPair {
interface LegacyRsaKeyPair {
public: string;
private: string;
keySizeBits: number;
}
const logger = Logger.create('RSA');
const rsa: RSA = {
generateKeyPair: async (keySize: number): Promise<RSAKeyPair> => {
if (shim.mobilePlatform() === 'web') {
// TODO: Try to implement with SubtleCrypto. May require migrating the RSA algorithm used on
// desktop and mobile (which is not supported on web). See commit 12adcd9dbc3f723bac36ff4447701573084c4694.
logger.warn('RSA on web is not yet supported.');
return null;
}
const keys: RSAKeyPair = await RnRSA.generateKeys(keySize);
const legacyRsa: PublicKeyCrypto = {
generateKeyPair: async () => {
const keySize = 2048;
const keys: LegacyRsaKeyPair = await RnRSA.generateKeys(keySize);
// Sanity check
if (!keys.private) throw new Error('No private key was generated');
if (!keys.public) throw new Error('No public key was generated');
return rsa.loadKeys(keys.public, keys.private, keySize);
const keyPair = await legacyRsa.loadKeys(keys.public, keys.private, keySize);
return {
keyPair,
keySize,
};
},
loadKeys: async (publicKey: string, privateKey: string, keySizeBits: number): Promise<RSAKeyPair> => {
maximumPlaintextLengthBytes: 190,
loadKeys: async (publicKey: string, privateKey: string, keySizeBits: number): Promise<LegacyRsaKeyPair> => {
return { public: publicKey, private: privateKey, keySizeBits };
},
encrypt: async (plaintextUtf8: string, rsaKeyPair: RSAKeyPair): Promise<string> => {
encrypt: async (plaintextUtf8: string, rsaKeyPair: LegacyRsaKeyPair) => {
// TODO: Support long-data encryption in a way compatible with node-rsa.
return RnRSA.encrypt(plaintextUtf8, rsaKeyPair.public);
return Buffer.from(await RnRSA.encrypt(plaintextUtf8, rsaKeyPair.public), 'base64');
},
decrypt: async (ciphertextBase64: string, rsaKeyPair: RSAKeyPair): Promise<string> => {
const ciphertextBuffer = Buffer.from(ciphertextBase64, 'base64');
decrypt: async (ciphertextBuffer: CiphertextBuffer, rsaKeyPair: LegacyRsaKeyPair): Promise<string> => {
const maximumEncryptedSize = Math.floor(rsaKeyPair.keySizeBits / 8); // Usually 256
// On iOS, .decrypt fails without throwing or rejecting.
@@ -75,20 +72,26 @@ const rsa: RSA = {
}
return result.join('');
} else {
const plainText = await RnRSA.decrypt(ciphertextBase64, rsaKeyPair.private);
const plainText = await RnRSA.decrypt(ciphertextBuffer.toString('base64'), rsaKeyPair.private);
handleError(plainText);
return plainText;
}
},
publicKey: (rsaKeyPair: RSAKeyPair): string => {
publicKey: async (rsaKeyPair: LegacyRsaKeyPair) => {
return rsaKeyPair.public;
},
privateKey: (rsaKeyPair: RSAKeyPair): string => {
privateKey: async (rsaKeyPair: LegacyRsaKeyPair) => {
return rsaKeyPair.private;
},
};
const rsa: PublicKeyCryptoProvider = {
[PublicKeyAlgorithm.Unknown]: null,
[PublicKeyAlgorithm.RsaV1]: legacyRsa,
[PublicKeyAlgorithm.RsaV2]: buildRsaCryptoProvider(PublicKeyAlgorithm.RsaV2, QuickCrypto as WebCryptoSlice),
[PublicKeyAlgorithm.RsaV3]: buildRsaCryptoProvider(PublicKeyAlgorithm.RsaV3, QuickCrypto as WebCryptoSlice),
};
export default rsa;

View File

@@ -0,0 +1,11 @@
import buildRsaCryptoProvider from '@joplin/lib/services/e2ee/ppk/webCrypto/buildRsaCryptoProvider';
import { PublicKeyAlgorithm, PublicKeyCryptoProvider } from '@joplin/lib/services/e2ee/types';
const rsa: PublicKeyCryptoProvider = {
[PublicKeyAlgorithm.Unknown]: null,
[PublicKeyAlgorithm.RsaV1]: null, // Unsupported on web
[PublicKeyAlgorithm.RsaV2]: buildRsaCryptoProvider(PublicKeyAlgorithm.RsaV2, crypto),
[PublicKeyAlgorithm.RsaV3]: buildRsaCryptoProvider(PublicKeyAlgorithm.RsaV3, crypto),
};
export default rsa;

View File

@@ -0,0 +1,207 @@
import reducer from '@joplin/lib/reducer';
import { AppState } from './types';
import appDefaultState from './appDefaultState';
import fastDeepEqual = require('fast-deep-equal');
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('appReducer');
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const navHistory: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function historyCanGoBackTo(route: any) {
if (route.routeName === 'Folder') return false;
// This is an intermediate screen that acts more like a modal -- it should be skipped in the
// navigation history.
if (route.routeName === 'DocumentScanner') return false;
// There's no point going back to these screens in general and, at least in OneDrive case,
// it can be buggy to do so, due to incorrectly relying on global state (reg.syncTarget...)
if (route.routeName === 'OneDriveLogin') return false;
if (route.routeName === 'DropboxLogin') return false;
return true;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const appReducer = (state = appDefaultState, action: any) => {
let newState = state;
let historyGoingBack = false;
try {
switch (action.type) {
case 'NAV_BACK':
case 'NAV_GO':
if (action.type === 'NAV_BACK') {
if (!navHistory.length) break;
const newAction = navHistory.pop();
action = newAction ? newAction : navHistory.pop();
historyGoingBack = true;
}
{
const currentRoute = state.route;
if (!historyGoingBack && historyCanGoBackTo(currentRoute)) {
const previousRoute = navHistory.length && navHistory[navHistory.length - 1];
const isDifferentRoute = !previousRoute || !fastDeepEqual(navHistory[navHistory.length - 1], currentRoute);
// Avoid multiple consecutive duplicate screens in the navigation history -- these can make
// pressing "back" seem to have no effect.
if (isDifferentRoute) {
navHistory.push(currentRoute);
}
}
if (action.clearHistory) {
navHistory.splice(0, navHistory.length);
}
newState = { ...state };
newState.selectedNoteHash = '';
if (action.routeName === 'Search') {
newState.notesParentType = 'Search';
}
if ('noteId' in action) {
newState.selectedNoteIds = action.noteId ? [action.noteId] : [];
}
if ('folderId' in action) {
newState.selectedFolderId = action.folderId;
newState.notesParentType = 'Folder';
}
if ('tagId' in action) {
newState.selectedTagId = action.tagId;
newState.notesParentType = 'Tag';
}
if ('smartFilterId' in action) {
newState.smartFilterId = action.smartFilterId;
newState.selectedSmartFilterId = action.smartFilterId;
newState.notesParentType = 'SmartFilter';
}
if ('itemType' in action) {
newState.selectedItemType = action.itemType;
}
if ('noteHash' in action) {
newState.selectedNoteHash = action.noteHash;
}
if ('sharedData' in action) {
newState.sharedData = action.sharedData;
} else {
newState.sharedData = null;
}
newState.route = action;
newState.historyCanGoBack = !!navHistory.length;
logger.debug('Navigated to route:', newState.route?.routeName, 'with notesParentType:', newState.notesParentType);
}
break;
case 'SIDE_MENU_TOGGLE':
newState = { ...state };
newState.showSideMenu = !newState.showSideMenu;
break;
case 'SIDE_MENU_OPEN':
newState = { ...state };
newState.showSideMenu = true;
break;
case 'SIDE_MENU_CLOSE':
newState = { ...state };
newState.showSideMenu = false;
break;
case 'SET_PLUGIN_PANELS_DIALOG_VISIBLE':
newState = { ...state };
newState.showPanelsDialog = action.visible;
break;
case 'NOTE_SELECTION_TOGGLE':
{
newState = { ...state };
const noteId = action.id;
const newSelectedNoteIds = state.selectedNoteIds.slice();
const existingIndex = state.selectedNoteIds.indexOf(noteId);
if (existingIndex >= 0) {
newSelectedNoteIds.splice(existingIndex, 1);
} else {
newSelectedNoteIds.push(noteId);
}
newState.selectedNoteIds = newSelectedNoteIds;
newState.noteSelectionEnabled = !!newSelectedNoteIds.length;
}
break;
case 'NOTE_SELECTION_START':
if (!state.noteSelectionEnabled) {
newState = { ...state };
newState.noteSelectionEnabled = true;
newState.selectedNoteIds = [action.id];
}
break;
case 'NOTE_SELECTION_END':
newState = { ...state };
newState.noteSelectionEnabled = false;
newState.selectedNoteIds = [];
break;
case 'NOTE_SIDE_MENU_OPTIONS_SET':
newState = { ...state };
newState.noteSideMenuOptions = action.options;
break;
case 'SET_SIDE_MENU_TOUCH_GESTURES_DISABLED':
newState = { ...state };
newState.disableSideMenuGestures = action.disableSideMenuGestures;
break;
case 'MOBILE_DATA_WARNING_UPDATE':
newState = { ...state };
newState.isOnMobileData = action.isOnMobileData;
break;
case 'KEYBOARD_VISIBLE_CHANGE':
newState = { ...state, keyboardVisible: action.visible };
break;
case 'NOTE_EDITOR_VISIBLE_CHANGE':
newState = { ...state, noteEditorVisible: action.visible };
break;
}
} catch (error) {
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
throw error;
}
return reducer(newState, action) as AppState;
};
export default appReducer;

View File

@@ -67,10 +67,10 @@ import MigrationService from '@joplin/lib/services/MigrationService';
import { clearSharedFilesCache } from '../utils/ShareUtils';
import setIgnoreTlsErrors from '../utils/TlsUtils';
import ShareService from '@joplin/lib/services/share/ShareService';
import { loadMasterKeysFromSettings, migrateMasterPassword } from '@joplin/lib/services/e2ee/utils';
import { setRSA } from '@joplin/lib/services/e2ee/ppk';
import { loadMasterKeysFromSettings, migrateMasterPassword, migratePpk } from '@joplin/lib/services/e2ee/utils';
import { setRSA } from '@joplin/lib/services/e2ee/ppk/ppk';
import RSA from '../services/e2ee/RSA.react-native';
import { runIntegrationTests as runRsaIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils';
import { runIntegrationTests as runRsaIntegrationTests } from '@joplin/lib/services/e2ee/ppk/ppkTestUtils';
import { runIntegrationTests as runCryptoIntegrationTests } from '@joplin/lib/services/e2ee/cryptoTestUtils';
import { getCurrentProfile } from '@joplin/lib/services/profileConfig';
import { getDatabaseName, getPluginDataDir, getProfilesRootDir, getResourceDir } from '../services/profiles';
@@ -356,6 +356,9 @@ const buildStartupTasks = (
addTask('buildStartupTasks/set up sharing', async () => {
await ShareService.instance().initialize(store, EncryptionService.instance());
});
addTask('buildStartupTasks/migrate PPK', async () => {
await migratePpk();
});
addTask('buildStartupTasks/load folders', async () => {
await refreshFolders(dispatch, '');
@@ -463,11 +466,7 @@ const buildStartupTasks = (
// just print some messages in the console.
// ----------------------------------------------------------------------------
if (Setting.value('env') === 'dev') {
if (Platform.OS !== 'web') {
await runRsaIntegrationTests();
} else {
logger.info('Skipping encryption tests -- not supported on web.');
}
await runRsaIntegrationTests();
await runCryptoIntegrationTests();
await runOnDeviceFsDriverTests();
}

View File

@@ -236,15 +236,20 @@ export class WorkerApi {
const folderName = removeReservedWords(basename(path));
let handle: FileSystemDirectoryHandle;
try {
handle = await parent.getDirectoryHandle(folderName, { create });
this.directoryHandleCache_.set(path, handle);
} catch (error) {
if (!isNotFoundError(error)) {
throw error;
}
if (!parent) {
logger.debug('Parent not found for path', path);
handle = null;
} else {
try {
handle = await parent.getDirectoryHandle(folderName, { create });
this.directoryHandleCache_.set(path, handle);
} catch (error) {
if (!isNotFoundError(error)) {
throw error;
}
handle = null;
}
}
return handle;

View File

@@ -0,0 +1,24 @@
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { useEffect, useState } from 'react';
import { AccessibilityInfo } from 'react-native';
let lastScreenReaderEnabled = false;
const useIsScreenReaderEnabled = () => {
const [screenReaderEnabled, setIsScreenReaderEnabled] = useState(lastScreenReaderEnabled);
useEffect(() => {
AccessibilityInfo.addEventListener('screenReaderChanged', (enabled) => {
lastScreenReaderEnabled = enabled;
setIsScreenReaderEnabled(enabled);
});
}, []);
useAsyncEffect(async () => {
const enabled = await AccessibilityInfo.isScreenReaderEnabled();
lastScreenReaderEnabled = enabled;
setIsScreenReaderEnabled(enabled);
}, []);
return screenReaderEnabled;
};
export default useIsScreenReaderEnabled;

View File

@@ -5,19 +5,26 @@ const useKeyboardState = () => {
const [keyboardVisible, setKeyboardVisible] = useState(false);
const [hasSoftwareKeyboard, setHasSoftwareKeyboard] = useState(false);
const [isFloatingKeyboard, setIsFloatingKeyboard] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
useEffect(() => {
const showListener = Keyboard.addListener('keyboardDidShow', () => {
const showListener = Keyboard.addListener('keyboardDidShow', (evt) => {
setKeyboardVisible(true);
setHasSoftwareKeyboard(true);
// Floating keyboards on Android result in a negative height being set when portrait
setKeyboardHeight(evt.endCoordinates.height > 0 ? evt.endCoordinates.height : 0);
});
const hideListener = Keyboard.addListener('keyboardDidHide', () => {
setKeyboardVisible(false);
setKeyboardHeight(0);
});
const floatingListener = Keyboard.addListener('keyboardWillChangeFrame', (evt) => {
// The keyboardWillChangeFrame event only applies to iOS as it does not exist on Android, in which case isFloatingKeyboard will always be false.
// But we only need to utilise isFloatingKeyboard to workaround a KeyboardAvoidingView issue on iOS
const windowWidth = Dimensions.get('window').width;
// If the keyboard isn't as wide as the window, the floating keyboard is disabled.
// See https://github.com/facebook/react-native/issues/29473#issuecomment-696658937
setIsFloatingKeyboard(evt.endCoordinates.width < windowWidth);
setKeyboardHeight(evt.endCoordinates.height);
});
return (() => {
@@ -28,8 +35,13 @@ const useKeyboardState = () => {
});
return useMemo(() => {
return { keyboardVisible, hasSoftwareKeyboard, isFloatingKeyboard };
}, [keyboardVisible, hasSoftwareKeyboard, isFloatingKeyboard]);
return {
keyboardVisible,
hasSoftwareKeyboard,
isFloatingKeyboard,
dockedKeyboardHeight: isFloatingKeyboard ? 0 : keyboardHeight,
};
}, [keyboardVisible, hasSoftwareKeyboard, isFloatingKeyboard, keyboardHeight]);
};
export default useKeyboardState;

View File

@@ -10,8 +10,8 @@ const useSafeAreaPadding = () => {
return isLandscape ? {
paddingRight: safeAreaInsets.right,
paddingLeft: safeAreaInsets.left,
paddingTop: 15,
paddingBottom: 15,
paddingTop: safeAreaInsets.top,
paddingBottom: 0,
} : {
paddingTop: safeAreaInsets.top,
paddingBottom: safeAreaInsets.bottom,

View File

@@ -0,0 +1,2 @@
// Only some of the React Native polyfills should be used on Web:
import './bufferPolyfill';

View File

@@ -1,15 +1,16 @@
import reducer from '@joplin/lib/reducer';
import { createStore } from 'redux';
import appDefaultState from '../appDefaultState';
import Setting from '@joplin/lib/models/Setting';
import { AppState } from '../types';
import appReducer from '../appReducer';
const testReducer = (state: AppState|undefined, action: unknown): AppState => {
state ??= {
...appDefaultState,
settings: Setting.toPlainObject(),
};
return { ...state, ...reducer(state, action) };
return { ...state, ...appReducer(state, action) };
};
const createMockReduxStore = () => {

View File

@@ -7,11 +7,11 @@ class MockPluginRunner extends BasePluginRunner {
public override async stop() {}
}
const pluginServiceSetup = (store: Store) => {
const mockPluginServiceSetup = (store: Store) => {
const runner = new MockPluginRunner();
PluginService.instance().initialize(
'2.14.0', { joplin: {} }, runner, store,
);
};
export default pluginServiceSetup;
export default mockPluginServiceSetup;

View File

@@ -13,6 +13,7 @@ import toggleInlineSelectionFormat from './utils/formatting/toggleInlineSelectio
import getSearchState from './utils/getSearchState';
import { noteIdFacet, setNoteIdEffect } from './extensions/selectedNoteIdExtension';
import jumpToHash from './editorCommands/jumpToHash';
import { resetImageResourceEffect } from './extensions/rendering/renderBlockImages';
interface Callbacks {
onUndoRedo(): void;
@@ -229,8 +230,12 @@ export default class CodeMirrorControl extends CodeMirror5Emulation implements E
};
}
public onResourceDownloaded(_id: string) {
// Unused
public onResourceChanged(id: string) {
this.editor.dispatch({
effects: [
resetImageResourceEffect.of({ id }),
],
});
}
public setContentScripts(plugins: ContentScriptData[]) {

View File

@@ -16,6 +16,8 @@ import { Prec } from '@codemirror/state';
import insertNewlineContinueMarkup from './editorCommands/insertNewlineContinueMarkup';
import renderingExtension from './extensions/rendering/renderingExtension';
import { RenderedContentContext } from './extensions/rendering/types';
import highlightActiveLineExtension from './extensions/highlightActiveLineExtension';
import renderBlockImages from './extensions/rendering/renderBlockImages';
const configFromSettings = (settings: EditorSettings, context: RenderedContentContext) => {
const languageExtension = (() => {
@@ -87,9 +89,15 @@ const configFromSettings = (settings: EditorSettings, context: RenderedContentCo
}
if (settings.inlineRenderingEnabled) {
extensions.push(renderingExtension(context, {
renderImages: settings.imageRenderingEnabled,
}));
extensions.push(renderingExtension());
}
if (settings.imageRenderingEnabled) {
extensions.push(renderBlockImages(context));
}
if (settings.highlightActiveLine) {
extensions.push(highlightActiveLineExtension());
}
return extensions;

View File

@@ -39,6 +39,7 @@ import selectedNoteIdExtension, { setNoteIdEffect } from './extensions/selectedN
import ctrlKeyStateClassExtension from './extensions/modifierKeyCssExtension';
import ctrlClickLinksExtension from './extensions/links/ctrlClickLinksExtension';
import { RenderedContentContext } from './extensions/rendering/types';
import ctrlClickCheckboxExtension from './extensions/ctrlClickCheckboxExtension';
// Newer versions of CodeMirror by default use Chrome's EditContext API.
// While this might be stable enough for desktop use, it causes significant
@@ -50,7 +51,7 @@ import { RenderedContentContext } from './extensions/rendering/types';
type ExtendedEditorView = typeof EditorView & { EDIT_CONTEXT: boolean };
(EditorView as ExtendedEditorView).EDIT_CONTEXT = false;
export type ResolveImageCallback = (imageSrc: string)=> Promise<string>;
export type ResolveImageCallback = (imageSrc: string, reloadCounter: number)=> Promise<string>;
interface CodeMirrorProps {
resolveImageSrc: ResolveImageCallback;
@@ -65,8 +66,8 @@ const createEditor = (
props.onLogMessage('Initializing CodeMirror...');
const context: RenderedContentContext = {
resolveImageSrc: (src) => {
return props.resolveImageSrc(src);
resolveImageSrc: (src, counter) => {
return props.resolveImageSrc(src, counter);
},
};
@@ -255,6 +256,7 @@ const createEditor = (
ctrlClickLinksExtension(link => {
props.onEvent({ kind: EditorEventType.FollowLink, link });
}),
ctrlClickCheckboxExtension(),
highlightSpecialChars(),
indentOnInput(),
@@ -351,6 +353,9 @@ const createEditor = (
onLogMessage: props.onLogMessage,
onRemove: () => {
editor.destroy();
props.onEvent({
kind: EditorEventType.Remove,
});
},
});

View File

@@ -0,0 +1,33 @@
import { EditorView } from '@codemirror/view';
import { Prec } from '@codemirror/state';
const hasMultipleCursors = (view: EditorView) => {
return view.state.selection.ranges.length > 1;
};
type OnCtrlClick = (view: EditorView, event: MouseEvent)=> boolean;
const ctrlClickActionExtension = (onCtrlClick: OnCtrlClick) => {
return [
Prec.high([
EditorView.domEventHandlers({
mousedown: (event: MouseEvent, view: EditorView) => {
const hasModifier = event.ctrlKey || event.metaKey;
// The default CodeMirror action for ctrl-click is to add another cursor
// to the document. If the user already has multiple cursors, assume that
// the ctrl-click action is intended to add another.
if (hasModifier && !hasMultipleCursors(view)) {
const handled = onCtrlClick(view, event);
if (handled) {
event.preventDefault();
return true;
}
}
return false;
},
}),
]),
];
};
export default ctrlClickActionExtension;

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