You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Mobile: Support building for web (#10650)
This commit is contained in:
		| @@ -53,6 +53,7 @@ packages/app-desktop/services/electron-context-menu.js | |||||||
| packages/app-desktop/vendor/lib/ | packages/app-desktop/vendor/lib/ | ||||||
| packages/app-mobile/android | packages/app-mobile/android | ||||||
| packages/app-mobile/**/*.bundle.js | packages/app-mobile/**/*.bundle.js | ||||||
|  | packages/app-mobile/web/public/pluginAssets/**/* | ||||||
| packages/app-mobile/ios | packages/app-mobile/ios | ||||||
| packages/app-mobile/lib/rnInjectedJs/ | packages/app-mobile/lib/rnInjectedJs/ | ||||||
| packages/app-mobile/locales | packages/app-mobile/locales | ||||||
| @@ -529,15 +530,17 @@ packages/app-mobile/commands/openItem.js | |||||||
| packages/app-mobile/commands/openNote.js | packages/app-mobile/commands/openNote.js | ||||||
| packages/app-mobile/commands/scrollToHash.js | packages/app-mobile/commands/scrollToHash.js | ||||||
| packages/app-mobile/commands/util/goToNote.js | packages/app-mobile/commands/util/goToNote.js | ||||||
| packages/app-mobile/components/ActionButton.js | packages/app-mobile/commands/util/showResource.js | ||||||
| packages/app-mobile/components/BackButtonDialogBox.js | packages/app-mobile/components/BackButtonDialogBox.js | ||||||
| packages/app-mobile/components/BetaChip.js | packages/app-mobile/components/BetaChip.js | ||||||
| packages/app-mobile/components/CameraView.js | packages/app-mobile/components/CameraView.js | ||||||
|  | packages/app-mobile/components/DialogManager.js | ||||||
| packages/app-mobile/components/DismissibleDialog.js | packages/app-mobile/components/DismissibleDialog.js | ||||||
| packages/app-mobile/components/Dropdown.test.js | packages/app-mobile/components/Dropdown.test.js | ||||||
| packages/app-mobile/components/Dropdown.js | packages/app-mobile/components/Dropdown.js | ||||||
| packages/app-mobile/components/ExtendedWebView/index.jest.js | packages/app-mobile/components/ExtendedWebView/index.jest.js | ||||||
| packages/app-mobile/components/ExtendedWebView/index.js | 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/types.js | ||||||
| packages/app-mobile/components/FolderPicker.js | packages/app-mobile/components/FolderPicker.js | ||||||
| packages/app-mobile/components/Icon.js | packages/app-mobile/components/Icon.js | ||||||
| @@ -601,15 +604,19 @@ packages/app-mobile/components/ProfileSwitcher/useProfileConfig.js | |||||||
| packages/app-mobile/components/ScreenHeader/WarningBanner.test.js | packages/app-mobile/components/ScreenHeader/WarningBanner.test.js | ||||||
| packages/app-mobile/components/ScreenHeader/WarningBanner.js | packages/app-mobile/components/ScreenHeader/WarningBanner.js | ||||||
| packages/app-mobile/components/ScreenHeader/WarningBox.js | packages/app-mobile/components/ScreenHeader/WarningBox.js | ||||||
|  | packages/app-mobile/components/ScreenHeader/WebBetaButton.js | ||||||
| packages/app-mobile/components/ScreenHeader/index.js | packages/app-mobile/components/ScreenHeader/index.js | ||||||
| packages/app-mobile/components/SelectDateTimeDialog.js | packages/app-mobile/components/SelectDateTimeDialog.js | ||||||
| packages/app-mobile/components/SideMenu.js | packages/app-mobile/components/SideMenu.js | ||||||
| packages/app-mobile/components/TextInput.js | packages/app-mobile/components/TextInput.js | ||||||
|  | packages/app-mobile/components/accessibility/AccessibleModalMenu.js | ||||||
|  | packages/app-mobile/components/accessibility/AccessibleView.js | ||||||
| packages/app-mobile/components/app-nav.js | packages/app-mobile/components/app-nav.js | ||||||
| packages/app-mobile/components/base-screen.js | packages/app-mobile/components/base-screen.js | ||||||
| packages/app-mobile/components/biometrics/BiometricPopup.js | packages/app-mobile/components/biometrics/BiometricPopup.js | ||||||
| packages/app-mobile/components/biometrics/biometricAuthenticate.js | packages/app-mobile/components/biometrics/biometricAuthenticate.js | ||||||
| packages/app-mobile/components/biometrics/sensorInfo.js | packages/app-mobile/components/biometrics/sensorInfo.js | ||||||
|  | packages/app-mobile/components/buttons/FloatingActionButton.js | ||||||
| packages/app-mobile/components/buttons/TextButton.js | packages/app-mobile/components/buttons/TextButton.js | ||||||
| packages/app-mobile/components/buttons/index.js | packages/app-mobile/components/buttons/index.js | ||||||
| packages/app-mobile/components/getResponsiveValue.test.js | packages/app-mobile/components/getResponsiveValue.test.js | ||||||
| @@ -623,7 +630,6 @@ packages/app-mobile/components/plugins/backgroundPage/pluginRunnerBackgroundPage | |||||||
| packages/app-mobile/components/plugins/backgroundPage/startStopPlugin.js | packages/app-mobile/components/plugins/backgroundPage/startStopPlugin.js | ||||||
| packages/app-mobile/components/plugins/backgroundPage/utils/getFormData.test.js | packages/app-mobile/components/plugins/backgroundPage/utils/getFormData.test.js | ||||||
| packages/app-mobile/components/plugins/backgroundPage/utils/getFormData.js | packages/app-mobile/components/plugins/backgroundPage/utils/getFormData.js | ||||||
| packages/app-mobile/components/plugins/backgroundPage/utils/makeSandboxedIframe.js |  | ||||||
| packages/app-mobile/components/plugins/backgroundPage/utils/reportUnhandledErrors.js | packages/app-mobile/components/plugins/backgroundPage/utils/reportUnhandledErrors.js | ||||||
| packages/app-mobile/components/plugins/backgroundPage/utils/wrapConsoleLog.js | packages/app-mobile/components/plugins/backgroundPage/utils/wrapConsoleLog.js | ||||||
| packages/app-mobile/components/plugins/dialogs/PluginDialogManager.js | packages/app-mobile/components/plugins/dialogs/PluginDialogManager.js | ||||||
| @@ -701,9 +707,11 @@ packages/app-mobile/components/screens/status.js | |||||||
| packages/app-mobile/components/side-menu-content.js | packages/app-mobile/components/side-menu-content.js | ||||||
| packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js | packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js | ||||||
| packages/app-mobile/gulpfile.js | packages/app-mobile/gulpfile.js | ||||||
|  | packages/app-mobile/index.web.js | ||||||
| packages/app-mobile/root.js | packages/app-mobile/root.js | ||||||
| packages/app-mobile/services/AlarmServiceDriver.android.js | packages/app-mobile/services/AlarmServiceDriver.android.js | ||||||
| packages/app-mobile/services/AlarmServiceDriver.ios.js | packages/app-mobile/services/AlarmServiceDriver.ios.js | ||||||
|  | packages/app-mobile/services/AlarmServiceDriver.web.js | ||||||
| packages/app-mobile/services/e2ee/RSA.react-native.js | packages/app-mobile/services/e2ee/RSA.react-native.js | ||||||
| packages/app-mobile/services/plugins/PlatformImplementation.js | packages/app-mobile/services/plugins/PlatformImplementation.js | ||||||
| packages/app-mobile/services/profiles/index.js | packages/app-mobile/services/profiles/index.js | ||||||
| @@ -714,6 +722,7 @@ packages/app-mobile/tools/buildInjectedJs/BundledFile.js | |||||||
| packages/app-mobile/tools/buildInjectedJs/constants.js | packages/app-mobile/tools/buildInjectedJs/constants.js | ||||||
| packages/app-mobile/tools/buildInjectedJs/copyJs.js | packages/app-mobile/tools/buildInjectedJs/copyJs.js | ||||||
| packages/app-mobile/tools/buildInjectedJs/gulpTasks.js | packages/app-mobile/tools/buildInjectedJs/gulpTasks.js | ||||||
|  | packages/app-mobile/tools/copyAssets.js | ||||||
| packages/app-mobile/utils/ShareExtension.js | packages/app-mobile/utils/ShareExtension.js | ||||||
| packages/app-mobile/utils/ShareUtils.test.js | packages/app-mobile/utils/ShareUtils.test.js | ||||||
| packages/app-mobile/utils/ShareUtils.js | packages/app-mobile/utils/ShareUtils.js | ||||||
| @@ -722,9 +731,13 @@ packages/app-mobile/utils/appDefaultState.js | |||||||
| packages/app-mobile/utils/autodetectTheme.js | packages/app-mobile/utils/autodetectTheme.js | ||||||
| packages/app-mobile/utils/checkPermissions.js | packages/app-mobile/utils/checkPermissions.js | ||||||
| packages/app-mobile/utils/createRootStyle.js | packages/app-mobile/utils/createRootStyle.js | ||||||
|  | packages/app-mobile/utils/database-driver-react-native.js | ||||||
|  | packages/app-mobile/utils/database-driver-react-native.web.js | ||||||
| packages/app-mobile/utils/debounce.js | packages/app-mobile/utils/debounce.js | ||||||
| packages/app-mobile/utils/fs-driver/constants.js | packages/app-mobile/utils/fs-driver/constants.js | ||||||
| packages/app-mobile/utils/fs-driver/fs-driver-rn.js | packages/app-mobile/utils/fs-driver/fs-driver-rn.js | ||||||
|  | packages/app-mobile/utils/fs-driver/fs-driver-rn.web.js | ||||||
|  | packages/app-mobile/utils/fs-driver/fs-driver-rn.web.worker.js | ||||||
| packages/app-mobile/utils/fs-driver/runOnDeviceTests.js | packages/app-mobile/utils/fs-driver/runOnDeviceTests.js | ||||||
| packages/app-mobile/utils/fs-driver/tarCreate.js | packages/app-mobile/utils/fs-driver/tarCreate.js | ||||||
| packages/app-mobile/utils/fs-driver/tarExtract.test.js | packages/app-mobile/utils/fs-driver/tarExtract.test.js | ||||||
| @@ -733,22 +746,29 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js | |||||||
| packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js | packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js | ||||||
| packages/app-mobile/utils/getPackageInfo.js | packages/app-mobile/utils/getPackageInfo.js | ||||||
| packages/app-mobile/utils/getVersionInfoText.js | packages/app-mobile/utils/getVersionInfoText.js | ||||||
|  | packages/app-mobile/utils/image/fileToImage.web.js | ||||||
| packages/app-mobile/utils/image/getImageDimensions.js | packages/app-mobile/utils/image/getImageDimensions.js | ||||||
| packages/app-mobile/utils/image/resizeImage.js | packages/app-mobile/utils/image/resizeImage.js | ||||||
| packages/app-mobile/utils/initializeCommandService.js | packages/app-mobile/utils/initializeCommandService.js | ||||||
| packages/app-mobile/utils/injectedJs.js | packages/app-mobile/utils/injectedJs.js | ||||||
| packages/app-mobile/utils/ipc/RNToWebViewMessenger.js | packages/app-mobile/utils/ipc/RNToWebViewMessenger.js | ||||||
| packages/app-mobile/utils/ipc/WebViewToRNMessenger.js | packages/app-mobile/utils/ipc/WebViewToRNMessenger.js | ||||||
|  | packages/app-mobile/utils/lockToSingleInstance.js | ||||||
|  | packages/app-mobile/utils/makeShowMessageBox.js | ||||||
| packages/app-mobile/utils/pickDocument.js | packages/app-mobile/utils/pickDocument.js | ||||||
| packages/app-mobile/utils/polyfills/bufferPolyfill.js | packages/app-mobile/utils/polyfills/bufferPolyfill.js | ||||||
| packages/app-mobile/utils/polyfills/index.js | packages/app-mobile/utils/polyfills/index.js | ||||||
| packages/app-mobile/utils/setupNotifications.js | packages/app-mobile/utils/setupNotifications.js | ||||||
|  | packages/app-mobile/utils/shareFile.js | ||||||
| packages/app-mobile/utils/shareHandler.js | packages/app-mobile/utils/shareHandler.js | ||||||
| packages/app-mobile/utils/shim-init-react.js | packages/app-mobile/utils/shim-init-react/index.js | ||||||
| packages/app-mobile/utils/showMessageBox.js | packages/app-mobile/utils/shim-init-react/index.web.js | ||||||
|  | packages/app-mobile/utils/shim-init-react/injectedJs.js | ||||||
|  | packages/app-mobile/utils/shim-init-react/shimInitShared.js | ||||||
| packages/app-mobile/utils/testing/createMockReduxStore.js | packages/app-mobile/utils/testing/createMockReduxStore.js | ||||||
| packages/app-mobile/utils/testing/getWebViewDomById.js | packages/app-mobile/utils/testing/getWebViewDomById.js | ||||||
| packages/app-mobile/utils/types.js | packages/app-mobile/utils/types.js | ||||||
|  | packages/app-mobile/web/serviceWorker.js | ||||||
| packages/default-plugins/build.js | packages/default-plugins/build.js | ||||||
| packages/default-plugins/buildDefaultPlugins.js | packages/default-plugins/buildDefaultPlugins.js | ||||||
| packages/default-plugins/commands/buildAll.js | packages/default-plugins/commands/buildAll.js | ||||||
| @@ -1282,13 +1302,17 @@ packages/lib/urlUtils.js | |||||||
| packages/lib/utils/ActionLogger.test.js | packages/lib/utils/ActionLogger.test.js | ||||||
| packages/lib/utils/ActionLogger.js | packages/lib/utils/ActionLogger.js | ||||||
| packages/lib/utils/credentialFiles.js | packages/lib/utils/credentialFiles.js | ||||||
|  | packages/lib/utils/dom/makeSandboxedIframe.js | ||||||
| packages/lib/utils/focusHandler.js | packages/lib/utils/focusHandler.js | ||||||
| packages/lib/utils/frontMatter.js | packages/lib/utils/frontMatter.js | ||||||
| packages/lib/utils/ipc/RemoteMessenger.test.js | packages/lib/utils/ipc/RemoteMessenger.test.js | ||||||
| packages/lib/utils/ipc/RemoteMessenger.js | packages/lib/utils/ipc/RemoteMessenger.js | ||||||
| packages/lib/utils/ipc/TestMessenger.js | packages/lib/utils/ipc/TestMessenger.js | ||||||
| packages/lib/utils/ipc/WindowMessenger.js | packages/lib/utils/ipc/WindowMessenger.js | ||||||
|  | packages/lib/utils/ipc/WorkerMessenger.js | ||||||
|  | packages/lib/utils/ipc/WorkerToWindowMessenger.js | ||||||
| packages/lib/utils/ipc/types.js | packages/lib/utils/ipc/types.js | ||||||
|  | packages/lib/utils/ipc/utils/isTransferableObject.js | ||||||
| packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.test.js | packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.test.js | ||||||
| packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.js | packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.js | ||||||
| packages/lib/utils/ipc/utils/separateCallbacksFromSerializable.test.js | packages/lib/utils/ipc/utils/separateCallbacksFromSerializable.test.js | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								.eslintrc.js
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								.eslintrc.js
									
									
									
									
									
								
							| @@ -15,6 +15,18 @@ module.exports = { | |||||||
| 	'globals': { | 	'globals': { | ||||||
| 		'Atomics': 'readonly', | 		'Atomics': 'readonly', | ||||||
| 		'SharedArrayBuffer': 'readonly', | 		'SharedArrayBuffer': 'readonly', | ||||||
|  | 		'BufferEncoding': 'readonly', | ||||||
|  | 		'AsyncIterable': 'readonly', | ||||||
|  | 		'FileSystemFileHandle': 'readonly', | ||||||
|  | 		'FileSystemDirectoryHandle': 'readonly', | ||||||
|  | 		'ReadableStreamDefaultReader': 'readonly', | ||||||
|  | 		'FileSystemCreateWritableOptions': 'readonly', | ||||||
|  | 		'FileSystemHandle': 'readonly', | ||||||
|  |  | ||||||
|  | 		// ServiceWorker | ||||||
|  | 		'ExtendableEvent': 'readonly', | ||||||
|  | 		'WindowClient': 'readonly', | ||||||
|  | 		'FetchEvent': 'readonly', | ||||||
|  |  | ||||||
| 		// Jest variables | 		// Jest variables | ||||||
| 		'test': 'readonly', | 		'test': 'readonly', | ||||||
|   | |||||||
							
								
								
									
										31
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										31
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -508,15 +508,17 @@ packages/app-mobile/commands/openItem.js | |||||||
| packages/app-mobile/commands/openNote.js | packages/app-mobile/commands/openNote.js | ||||||
| packages/app-mobile/commands/scrollToHash.js | packages/app-mobile/commands/scrollToHash.js | ||||||
| packages/app-mobile/commands/util/goToNote.js | packages/app-mobile/commands/util/goToNote.js | ||||||
| packages/app-mobile/components/ActionButton.js | packages/app-mobile/commands/util/showResource.js | ||||||
| packages/app-mobile/components/BackButtonDialogBox.js | packages/app-mobile/components/BackButtonDialogBox.js | ||||||
| packages/app-mobile/components/BetaChip.js | packages/app-mobile/components/BetaChip.js | ||||||
| packages/app-mobile/components/CameraView.js | packages/app-mobile/components/CameraView.js | ||||||
|  | packages/app-mobile/components/DialogManager.js | ||||||
| packages/app-mobile/components/DismissibleDialog.js | packages/app-mobile/components/DismissibleDialog.js | ||||||
| packages/app-mobile/components/Dropdown.test.js | packages/app-mobile/components/Dropdown.test.js | ||||||
| packages/app-mobile/components/Dropdown.js | packages/app-mobile/components/Dropdown.js | ||||||
| packages/app-mobile/components/ExtendedWebView/index.jest.js | packages/app-mobile/components/ExtendedWebView/index.jest.js | ||||||
| packages/app-mobile/components/ExtendedWebView/index.js | 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/types.js | ||||||
| packages/app-mobile/components/FolderPicker.js | packages/app-mobile/components/FolderPicker.js | ||||||
| packages/app-mobile/components/Icon.js | packages/app-mobile/components/Icon.js | ||||||
| @@ -580,15 +582,19 @@ packages/app-mobile/components/ProfileSwitcher/useProfileConfig.js | |||||||
| packages/app-mobile/components/ScreenHeader/WarningBanner.test.js | packages/app-mobile/components/ScreenHeader/WarningBanner.test.js | ||||||
| packages/app-mobile/components/ScreenHeader/WarningBanner.js | packages/app-mobile/components/ScreenHeader/WarningBanner.js | ||||||
| packages/app-mobile/components/ScreenHeader/WarningBox.js | packages/app-mobile/components/ScreenHeader/WarningBox.js | ||||||
|  | packages/app-mobile/components/ScreenHeader/WebBetaButton.js | ||||||
| packages/app-mobile/components/ScreenHeader/index.js | packages/app-mobile/components/ScreenHeader/index.js | ||||||
| packages/app-mobile/components/SelectDateTimeDialog.js | packages/app-mobile/components/SelectDateTimeDialog.js | ||||||
| packages/app-mobile/components/SideMenu.js | packages/app-mobile/components/SideMenu.js | ||||||
| packages/app-mobile/components/TextInput.js | packages/app-mobile/components/TextInput.js | ||||||
|  | packages/app-mobile/components/accessibility/AccessibleModalMenu.js | ||||||
|  | packages/app-mobile/components/accessibility/AccessibleView.js | ||||||
| packages/app-mobile/components/app-nav.js | packages/app-mobile/components/app-nav.js | ||||||
| packages/app-mobile/components/base-screen.js | packages/app-mobile/components/base-screen.js | ||||||
| packages/app-mobile/components/biometrics/BiometricPopup.js | packages/app-mobile/components/biometrics/BiometricPopup.js | ||||||
| packages/app-mobile/components/biometrics/biometricAuthenticate.js | packages/app-mobile/components/biometrics/biometricAuthenticate.js | ||||||
| packages/app-mobile/components/biometrics/sensorInfo.js | packages/app-mobile/components/biometrics/sensorInfo.js | ||||||
|  | packages/app-mobile/components/buttons/FloatingActionButton.js | ||||||
| packages/app-mobile/components/buttons/TextButton.js | packages/app-mobile/components/buttons/TextButton.js | ||||||
| packages/app-mobile/components/buttons/index.js | packages/app-mobile/components/buttons/index.js | ||||||
| packages/app-mobile/components/getResponsiveValue.test.js | packages/app-mobile/components/getResponsiveValue.test.js | ||||||
| @@ -602,7 +608,6 @@ packages/app-mobile/components/plugins/backgroundPage/pluginRunnerBackgroundPage | |||||||
| packages/app-mobile/components/plugins/backgroundPage/startStopPlugin.js | packages/app-mobile/components/plugins/backgroundPage/startStopPlugin.js | ||||||
| packages/app-mobile/components/plugins/backgroundPage/utils/getFormData.test.js | packages/app-mobile/components/plugins/backgroundPage/utils/getFormData.test.js | ||||||
| packages/app-mobile/components/plugins/backgroundPage/utils/getFormData.js | packages/app-mobile/components/plugins/backgroundPage/utils/getFormData.js | ||||||
| packages/app-mobile/components/plugins/backgroundPage/utils/makeSandboxedIframe.js |  | ||||||
| packages/app-mobile/components/plugins/backgroundPage/utils/reportUnhandledErrors.js | packages/app-mobile/components/plugins/backgroundPage/utils/reportUnhandledErrors.js | ||||||
| packages/app-mobile/components/plugins/backgroundPage/utils/wrapConsoleLog.js | packages/app-mobile/components/plugins/backgroundPage/utils/wrapConsoleLog.js | ||||||
| packages/app-mobile/components/plugins/dialogs/PluginDialogManager.js | packages/app-mobile/components/plugins/dialogs/PluginDialogManager.js | ||||||
| @@ -680,9 +685,11 @@ packages/app-mobile/components/screens/status.js | |||||||
| packages/app-mobile/components/side-menu-content.js | packages/app-mobile/components/side-menu-content.js | ||||||
| packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js | packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js | ||||||
| packages/app-mobile/gulpfile.js | packages/app-mobile/gulpfile.js | ||||||
|  | packages/app-mobile/index.web.js | ||||||
| packages/app-mobile/root.js | packages/app-mobile/root.js | ||||||
| packages/app-mobile/services/AlarmServiceDriver.android.js | packages/app-mobile/services/AlarmServiceDriver.android.js | ||||||
| packages/app-mobile/services/AlarmServiceDriver.ios.js | packages/app-mobile/services/AlarmServiceDriver.ios.js | ||||||
|  | packages/app-mobile/services/AlarmServiceDriver.web.js | ||||||
| packages/app-mobile/services/e2ee/RSA.react-native.js | packages/app-mobile/services/e2ee/RSA.react-native.js | ||||||
| packages/app-mobile/services/plugins/PlatformImplementation.js | packages/app-mobile/services/plugins/PlatformImplementation.js | ||||||
| packages/app-mobile/services/profiles/index.js | packages/app-mobile/services/profiles/index.js | ||||||
| @@ -693,6 +700,7 @@ packages/app-mobile/tools/buildInjectedJs/BundledFile.js | |||||||
| packages/app-mobile/tools/buildInjectedJs/constants.js | packages/app-mobile/tools/buildInjectedJs/constants.js | ||||||
| packages/app-mobile/tools/buildInjectedJs/copyJs.js | packages/app-mobile/tools/buildInjectedJs/copyJs.js | ||||||
| packages/app-mobile/tools/buildInjectedJs/gulpTasks.js | packages/app-mobile/tools/buildInjectedJs/gulpTasks.js | ||||||
|  | packages/app-mobile/tools/copyAssets.js | ||||||
| packages/app-mobile/utils/ShareExtension.js | packages/app-mobile/utils/ShareExtension.js | ||||||
| packages/app-mobile/utils/ShareUtils.test.js | packages/app-mobile/utils/ShareUtils.test.js | ||||||
| packages/app-mobile/utils/ShareUtils.js | packages/app-mobile/utils/ShareUtils.js | ||||||
| @@ -701,9 +709,13 @@ packages/app-mobile/utils/appDefaultState.js | |||||||
| packages/app-mobile/utils/autodetectTheme.js | packages/app-mobile/utils/autodetectTheme.js | ||||||
| packages/app-mobile/utils/checkPermissions.js | packages/app-mobile/utils/checkPermissions.js | ||||||
| packages/app-mobile/utils/createRootStyle.js | packages/app-mobile/utils/createRootStyle.js | ||||||
|  | packages/app-mobile/utils/database-driver-react-native.js | ||||||
|  | packages/app-mobile/utils/database-driver-react-native.web.js | ||||||
| packages/app-mobile/utils/debounce.js | packages/app-mobile/utils/debounce.js | ||||||
| packages/app-mobile/utils/fs-driver/constants.js | packages/app-mobile/utils/fs-driver/constants.js | ||||||
| packages/app-mobile/utils/fs-driver/fs-driver-rn.js | packages/app-mobile/utils/fs-driver/fs-driver-rn.js | ||||||
|  | packages/app-mobile/utils/fs-driver/fs-driver-rn.web.js | ||||||
|  | packages/app-mobile/utils/fs-driver/fs-driver-rn.web.worker.js | ||||||
| packages/app-mobile/utils/fs-driver/runOnDeviceTests.js | packages/app-mobile/utils/fs-driver/runOnDeviceTests.js | ||||||
| packages/app-mobile/utils/fs-driver/tarCreate.js | packages/app-mobile/utils/fs-driver/tarCreate.js | ||||||
| packages/app-mobile/utils/fs-driver/tarExtract.test.js | packages/app-mobile/utils/fs-driver/tarExtract.test.js | ||||||
| @@ -712,22 +724,29 @@ packages/app-mobile/utils/fs-driver/testUtil/createFilesFromPathRecord.js | |||||||
| packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js | packages/app-mobile/utils/fs-driver/testUtil/verifyDirectoryMatches.js | ||||||
| packages/app-mobile/utils/getPackageInfo.js | packages/app-mobile/utils/getPackageInfo.js | ||||||
| packages/app-mobile/utils/getVersionInfoText.js | packages/app-mobile/utils/getVersionInfoText.js | ||||||
|  | packages/app-mobile/utils/image/fileToImage.web.js | ||||||
| packages/app-mobile/utils/image/getImageDimensions.js | packages/app-mobile/utils/image/getImageDimensions.js | ||||||
| packages/app-mobile/utils/image/resizeImage.js | packages/app-mobile/utils/image/resizeImage.js | ||||||
| packages/app-mobile/utils/initializeCommandService.js | packages/app-mobile/utils/initializeCommandService.js | ||||||
| packages/app-mobile/utils/injectedJs.js | packages/app-mobile/utils/injectedJs.js | ||||||
| packages/app-mobile/utils/ipc/RNToWebViewMessenger.js | packages/app-mobile/utils/ipc/RNToWebViewMessenger.js | ||||||
| packages/app-mobile/utils/ipc/WebViewToRNMessenger.js | packages/app-mobile/utils/ipc/WebViewToRNMessenger.js | ||||||
|  | packages/app-mobile/utils/lockToSingleInstance.js | ||||||
|  | packages/app-mobile/utils/makeShowMessageBox.js | ||||||
| packages/app-mobile/utils/pickDocument.js | packages/app-mobile/utils/pickDocument.js | ||||||
| packages/app-mobile/utils/polyfills/bufferPolyfill.js | packages/app-mobile/utils/polyfills/bufferPolyfill.js | ||||||
| packages/app-mobile/utils/polyfills/index.js | packages/app-mobile/utils/polyfills/index.js | ||||||
| packages/app-mobile/utils/setupNotifications.js | packages/app-mobile/utils/setupNotifications.js | ||||||
|  | packages/app-mobile/utils/shareFile.js | ||||||
| packages/app-mobile/utils/shareHandler.js | packages/app-mobile/utils/shareHandler.js | ||||||
| packages/app-mobile/utils/shim-init-react.js | packages/app-mobile/utils/shim-init-react/index.js | ||||||
| packages/app-mobile/utils/showMessageBox.js | packages/app-mobile/utils/shim-init-react/index.web.js | ||||||
|  | packages/app-mobile/utils/shim-init-react/injectedJs.js | ||||||
|  | packages/app-mobile/utils/shim-init-react/shimInitShared.js | ||||||
| packages/app-mobile/utils/testing/createMockReduxStore.js | packages/app-mobile/utils/testing/createMockReduxStore.js | ||||||
| packages/app-mobile/utils/testing/getWebViewDomById.js | packages/app-mobile/utils/testing/getWebViewDomById.js | ||||||
| packages/app-mobile/utils/types.js | packages/app-mobile/utils/types.js | ||||||
|  | packages/app-mobile/web/serviceWorker.js | ||||||
| packages/default-plugins/build.js | packages/default-plugins/build.js | ||||||
| packages/default-plugins/buildDefaultPlugins.js | packages/default-plugins/buildDefaultPlugins.js | ||||||
| packages/default-plugins/commands/buildAll.js | packages/default-plugins/commands/buildAll.js | ||||||
| @@ -1261,13 +1280,17 @@ packages/lib/urlUtils.js | |||||||
| packages/lib/utils/ActionLogger.test.js | packages/lib/utils/ActionLogger.test.js | ||||||
| packages/lib/utils/ActionLogger.js | packages/lib/utils/ActionLogger.js | ||||||
| packages/lib/utils/credentialFiles.js | packages/lib/utils/credentialFiles.js | ||||||
|  | packages/lib/utils/dom/makeSandboxedIframe.js | ||||||
| packages/lib/utils/focusHandler.js | packages/lib/utils/focusHandler.js | ||||||
| packages/lib/utils/frontMatter.js | packages/lib/utils/frontMatter.js | ||||||
| packages/lib/utils/ipc/RemoteMessenger.test.js | packages/lib/utils/ipc/RemoteMessenger.test.js | ||||||
| packages/lib/utils/ipc/RemoteMessenger.js | packages/lib/utils/ipc/RemoteMessenger.js | ||||||
| packages/lib/utils/ipc/TestMessenger.js | packages/lib/utils/ipc/TestMessenger.js | ||||||
| packages/lib/utils/ipc/WindowMessenger.js | packages/lib/utils/ipc/WindowMessenger.js | ||||||
|  | packages/lib/utils/ipc/WorkerMessenger.js | ||||||
|  | packages/lib/utils/ipc/WorkerToWindowMessenger.js | ||||||
| packages/lib/utils/ipc/types.js | packages/lib/utils/ipc/types.js | ||||||
|  | packages/lib/utils/ipc/utils/isTransferableObject.js | ||||||
| packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.test.js | packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.test.js | ||||||
| packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.js | packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.js | ||||||
| packages/lib/utils/ipc/utils/separateCallbacksFromSerializable.test.js | packages/lib/utils/ipc/utils/separateCallbacksFromSerializable.test.js | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								packages/app-mobile/.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								packages/app-mobile/.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -70,6 +70,7 @@ components/**/*.bundle.js | |||||||
| components/**/*.bundle.js.LICENSE.txt | components/**/*.bundle.js.LICENSE.txt | ||||||
| components/**/*.bundle.js.md5 | components/**/*.bundle.js.md5 | ||||||
| components/**/*.bundle.min.js | components/**/*.bundle.min.js | ||||||
|  | web/public/pluginAssets/* | ||||||
|  |  | ||||||
| utils/fs-driver-android.js | utils/fs-driver-android.js | ||||||
| android/app/build-* | android/app/build-* | ||||||
|   | |||||||
| @@ -3,12 +3,14 @@ const { dirname } = require('@joplin/lib/path-utils'); | |||||||
| import Setting from '@joplin/lib/models/Setting'; | import Setting from '@joplin/lib/models/Setting'; | ||||||
| const pluginAssets = require('./pluginAssets/index'); | const pluginAssets = require('./pluginAssets/index'); | ||||||
| import KvStore from '@joplin/lib/services/KvStore'; | import KvStore from '@joplin/lib/services/KvStore'; | ||||||
|  | import Logger from '@joplin/utils/Logger'; | ||||||
|  | import FsDriverWeb from './utils/fs-driver/fs-driver-rn.web'; | ||||||
|  |  | ||||||
|  | const logger = Logger.create('PluginAssetsLoader'); | ||||||
|  |  | ||||||
| export default class PluginAssetsLoader { | export default class PluginAssetsLoader { | ||||||
|  |  | ||||||
| 	private static instance_: PluginAssetsLoader = null; | 	private static instance_: PluginAssetsLoader = null; | ||||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied |  | ||||||
| 	private logger_: any = null; |  | ||||||
|  |  | ||||||
| 	public static instance() { | 	public static instance() { | ||||||
| 		if (PluginAssetsLoader.instance_) return PluginAssetsLoader.instance_; | 		if (PluginAssetsLoader.instance_) return PluginAssetsLoader.instance_; | ||||||
| @@ -16,39 +18,58 @@ export default class PluginAssetsLoader { | |||||||
| 		return PluginAssetsLoader.instance_; | 		return PluginAssetsLoader.instance_; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | 	private destDir_() { | ||||||
| 	public setLogger(logger: any) { | 		return `${Setting.value('resourceDir')}/pluginAssets`; | ||||||
| 		this.logger_ = logger; |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	public logger() { | 	private async importAssetsMobile_() { | ||||||
| 		return this.logger_; | 		const destDir = this.destDir_(); | ||||||
|  |  | ||||||
|  | 		for (const name in pluginAssets.files) { | ||||||
|  | 			const dataBase64 = pluginAssets.files[name].data; | ||||||
|  | 			const destPath = `${destDir}/${name}`; | ||||||
|  | 			await shim.fsDriver().mkdir(dirname(destPath)); | ||||||
|  | 			await shim.fsDriver().unlink(destPath); | ||||||
|  |  | ||||||
|  | 			logger.info(`PluginAssetsLoader: Copying: ${name} => ${destPath}`); | ||||||
|  | 			await shim.fsDriver().writeFile(destPath, dataBase64); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	private async importAssetsWeb_() { | ||||||
|  | 		const destDir = this.destDir_(); | ||||||
|  | 		const fsDriver = shim.fsDriver() as FsDriverWeb; | ||||||
|  |  | ||||||
|  | 		await Promise.all(pluginAssets.files.map(async (name: string) => { | ||||||
|  | 			const destPath = `${destDir}/${name}`; | ||||||
|  | 			const response = await fetch(`pluginAssets/${name}`); | ||||||
|  | 			await shim.fsDriver().mkdir(dirname(destPath)); | ||||||
|  |  | ||||||
|  | 			await shim.fsDriver().unlink(destPath); | ||||||
|  | 			await fsDriver.writeFile(destPath, await response.arrayBuffer(), 'Buffer'); | ||||||
|  | 		})); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	public async importAssets() { | 	public async importAssets() { | ||||||
| 		const destDir = `${Setting.value('resourceDir')}/pluginAssets`; | 		const destDir = this.destDir_(); | ||||||
| 		await shim.fsDriver().mkdir(destDir); | 		await shim.fsDriver().mkdir(destDir); | ||||||
|  |  | ||||||
| 		const hash = pluginAssets.hash; | 		const hash = pluginAssets.hash; | ||||||
| 		if (hash === await KvStore.instance().value('PluginAssetsLoader.lastHash')) { | 		if (hash === await KvStore.instance().value('PluginAssetsLoader.lastHash')) { | ||||||
| 			this.logger().info(`PluginAssetsLoader: Assets are up to date. Hash: ${hash}`); | 			logger.info(`PluginAssetsLoader: Assets are up to date. Hash: ${hash}`); | ||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		this.logger().info(`PluginAssetsLoader: Importing assets to ${destDir}`); | 		logger.info(`PluginAssetsLoader: Importing assets to ${destDir}`); | ||||||
|  |  | ||||||
| 		try { | 		try { | ||||||
| 			for (const name in pluginAssets.files) { | 			if (shim.mobilePlatform() === 'web') { | ||||||
| 				const dataBase64 = pluginAssets.files[name].data; | 				await this.importAssetsWeb_(); | ||||||
| 				const destPath = `${destDir}/${name}`; | 			} else { | ||||||
| 				await shim.fsDriver().mkdir(dirname(destPath)); | 				await this.importAssetsMobile_(); | ||||||
| 				await shim.fsDriver().unlink(destPath); |  | ||||||
|  |  | ||||||
| 				this.logger().info(`PluginAssetsLoader: Copying: ${name} => ${destPath}`); |  | ||||||
| 				await shim.fsDriver().writeFile(destPath, dataBase64); |  | ||||||
| 			} | 			} | ||||||
| 		} catch (error) { | 		} catch (error) { | ||||||
| 			this.logger().error(error); | 			logger.error(error); | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		await KvStore.instance().setValue('PluginAssetsLoader.lastHash', hash); | 		await KvStore.instance().setValue('PluginAssetsLoader.lastHash', hash); | ||||||
|   | |||||||
| @@ -4,6 +4,10 @@ import { _ } from '@joplin/lib/locale'; | |||||||
| import { parseResourceUrl, urlProtocol } from '@joplin/lib/urlUtils'; | import { parseResourceUrl, urlProtocol } from '@joplin/lib/urlUtils'; | ||||||
| import Logger from '@joplin/utils/Logger'; | import Logger from '@joplin/utils/Logger'; | ||||||
| import goToNote from './util/goToNote'; | import goToNote from './util/goToNote'; | ||||||
|  | import BaseItem from '@joplin/lib/models/BaseItem'; | ||||||
|  | import { BaseItemEntity } from '@joplin/lib/services/database/types'; | ||||||
|  | import { ModelType } from '@joplin/lib/BaseModel'; | ||||||
|  | import showResource from './util/showResource'; | ||||||
|  |  | ||||||
| const logger = Logger.create('openItemCommand'); | const logger = Logger.create('openItemCommand'); | ||||||
|  |  | ||||||
| @@ -22,7 +26,14 @@ export const runtime = (): CommandRuntime => { | |||||||
| 					const { itemId, hash } = parsedUrl; | 					const { itemId, hash } = parsedUrl; | ||||||
|  |  | ||||||
| 					logger.info(`Navigating to item ${itemId}`); | 					logger.info(`Navigating to item ${itemId}`); | ||||||
| 					await goToNote(itemId, hash); | 					const item: BaseItemEntity = await BaseItem.loadItemById(itemId); | ||||||
|  | 					if (item.type_ === ModelType.Note) { | ||||||
|  | 						await goToNote(itemId, hash); | ||||||
|  | 					} else if (item.type_ === ModelType.Resource) { | ||||||
|  | 						await showResource(item); | ||||||
|  | 					} else { | ||||||
|  | 						logger.error('Unsupported item type for links:', item.type_); | ||||||
|  | 					} | ||||||
| 				} else { | 				} else { | ||||||
| 					logger.error(`Invalid Joplin link: ${link}`); | 					logger.error(`Invalid Joplin link: ${link}`); | ||||||
| 				} | 				} | ||||||
|   | |||||||
							
								
								
									
										25
									
								
								packages/app-mobile/commands/util/showResource.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								packages/app-mobile/commands/util/showResource.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | |||||||
|  | import Resource from '@joplin/lib/models/Resource'; | ||||||
|  | import { ResourceEntity } from '@joplin/lib/services/database/types'; | ||||||
|  | import shim from '@joplin/lib/shim'; | ||||||
|  | import Logger from '@joplin/utils/Logger'; | ||||||
|  | const FileViewer = require('react-native-file-viewer').default; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | const logger = Logger.create('showResource'); | ||||||
|  |  | ||||||
|  | const showResource = async (item: ResourceEntity) => { | ||||||
|  | 	const resourcePath = Resource.fullPath(item); | ||||||
|  | 	logger.info(`Opening resource: ${resourcePath}`); | ||||||
|  |  | ||||||
|  | 	if (shim.mobilePlatform() === 'web') { | ||||||
|  | 		const url = URL.createObjectURL(await shim.fsDriver().fileAtPath(resourcePath)); | ||||||
|  | 		const w = window.open(url, '_blank'); | ||||||
|  | 		w.addEventListener('close', () => { | ||||||
|  | 			URL.revokeObjectURL(url); | ||||||
|  | 		}, { once: true }); | ||||||
|  | 	} else { | ||||||
|  | 		await FileViewer.open(resourcePath); | ||||||
|  | 	} | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default showResource; | ||||||
							
								
								
									
										150
									
								
								packages/app-mobile/components/DialogManager.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								packages/app-mobile/components/DialogManager.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,150 @@ | |||||||
|  | import * as React from 'react'; | ||||||
|  | import { createContext, useEffect, useMemo, useRef, useState } from 'react'; | ||||||
|  | import { Alert, Platform, StyleSheet } from 'react-native'; | ||||||
|  | import { Button, Dialog, Portal, Text } from 'react-native-paper'; | ||||||
|  | import Modal from './Modal'; | ||||||
|  | import { _ } from '@joplin/lib/locale'; | ||||||
|  | import shim from '@joplin/lib/shim'; | ||||||
|  | import makeShowMessageBox from '../utils/makeShowMessageBox'; | ||||||
|  |  | ||||||
|  | export interface PromptButton { | ||||||
|  | 	text: string; | ||||||
|  | 	onPress?: ()=> void; | ||||||
|  | 	style?: 'cancel'|'default'|'destructive'; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface PromptOptions { | ||||||
|  | 	cancelable?: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface DialogControl { | ||||||
|  | 	prompt(title: string, message: string, buttons?: PromptButton[], options?: PromptOptions): void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const DialogContext = createContext<DialogControl>(null); | ||||||
|  |  | ||||||
|  | interface Props { | ||||||
|  | 	children: React.ReactNode; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface PromptDialogData { | ||||||
|  | 	key: string; | ||||||
|  | 	title: string; | ||||||
|  | 	message: string; | ||||||
|  | 	buttons: PromptButton[]; | ||||||
|  | 	onDismiss: (()=> void)|null; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const styles = StyleSheet.create({ | ||||||
|  | 	dialogContainer: { | ||||||
|  | 		maxWidth: 400, | ||||||
|  | 		minWidth: '50%', | ||||||
|  | 		alignSelf: 'center', | ||||||
|  | 	}, | ||||||
|  | 	modalContainer: { | ||||||
|  | 		marginTop: 'auto', | ||||||
|  | 		marginBottom: 'auto', | ||||||
|  | 	}, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const DialogManager: React.FC<Props> = props => { | ||||||
|  | 	const [dialogModels, setPromptDialogs] = useState<PromptDialogData[]>([]); | ||||||
|  | 	const nextDialogIdRef = useRef(0); | ||||||
|  |  | ||||||
|  | 	const dialogControl: DialogControl = useMemo(() => { | ||||||
|  | 		const defaultButtons = [{ text: _('OK') }]; | ||||||
|  | 		return { | ||||||
|  | 			prompt: (title: string, message: string, buttons: PromptButton[] = defaultButtons, options?: PromptOptions) => { | ||||||
|  | 				if (Platform.OS !== 'web') { | ||||||
|  | 					// Alert.alert provides a more native style on iOS. | ||||||
|  | 					Alert.alert(title, message, buttons, options); | ||||||
|  |  | ||||||
|  | 					// Alert.alert doesn't work on web. | ||||||
|  | 				} else { | ||||||
|  | 					const onDismiss = () => { | ||||||
|  | 						setPromptDialogs(dialogs => dialogs.filter(d => d !== dialog)); | ||||||
|  | 					}; | ||||||
|  |  | ||||||
|  | 					const cancelable = options?.cancelable ?? true; | ||||||
|  | 					const dialog: PromptDialogData = { | ||||||
|  | 						key: `dialog-${nextDialogIdRef.current++}`, | ||||||
|  | 						title, | ||||||
|  | 						message, | ||||||
|  | 						buttons: buttons.map(button => ({ | ||||||
|  | 							...button, | ||||||
|  | 							onPress: () => { | ||||||
|  | 								onDismiss(); | ||||||
|  | 								button.onPress?.(); | ||||||
|  | 							}, | ||||||
|  | 						})), | ||||||
|  | 						onDismiss: cancelable ? onDismiss : null, | ||||||
|  | 					}; | ||||||
|  |  | ||||||
|  | 					setPromptDialogs(dialogs => { | ||||||
|  | 						return [ | ||||||
|  | 							...dialogs, | ||||||
|  | 							dialog, | ||||||
|  | 						]; | ||||||
|  | 					}); | ||||||
|  | 				} | ||||||
|  | 			}, | ||||||
|  | 		}; | ||||||
|  | 	}, []); | ||||||
|  | 	const dialogControlRef = useRef(dialogControl); | ||||||
|  | 	dialogControlRef.current = dialogControl; | ||||||
|  |  | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		shim.showMessageBox = makeShowMessageBox(dialogControlRef); | ||||||
|  |  | ||||||
|  | 		return () => { | ||||||
|  | 			dialogControlRef.current = null; | ||||||
|  | 		}; | ||||||
|  | 	}, []); | ||||||
|  |  | ||||||
|  | 	const dialogComponents: React.ReactNode[] = []; | ||||||
|  | 	for (const dialog of dialogModels) { | ||||||
|  | 		const buttons = dialog.buttons.map((button, index) => { | ||||||
|  | 			return ( | ||||||
|  | 				<Button key={`${index}-${button.text}`} onPress={button.onPress}>{button.text}</Button> | ||||||
|  | 			); | ||||||
|  | 		}); | ||||||
|  | 		dialogComponents.push( | ||||||
|  | 			<Dialog | ||||||
|  | 				testID={'prompt-dialog'} | ||||||
|  | 				style={styles.dialogContainer} | ||||||
|  | 				key={dialog.key} | ||||||
|  | 				visible={true} | ||||||
|  | 				onDismiss={dialog.onDismiss} | ||||||
|  | 			> | ||||||
|  | 				<Dialog.Title>{dialog.title}</Dialog.Title> | ||||||
|  | 				<Dialog.Content> | ||||||
|  | 					<Text variant='bodyMedium'>{dialog.message}</Text> | ||||||
|  | 				</Dialog.Content> | ||||||
|  | 				<Dialog.Actions> | ||||||
|  | 					{buttons} | ||||||
|  | 				</Dialog.Actions> | ||||||
|  | 			</Dialog>, | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	// Web: Use a <Modal> wrapper for better keyboard focus handling. | ||||||
|  | 	return <> | ||||||
|  | 		<DialogContext.Provider value={dialogControl}> | ||||||
|  | 			{props.children} | ||||||
|  | 		</DialogContext.Provider> | ||||||
|  | 		<Portal> | ||||||
|  | 			<Modal | ||||||
|  | 				visible={!!dialogComponents.length} | ||||||
|  | 				containerStyle={styles.modalContainer} | ||||||
|  | 				animationType='none' | ||||||
|  | 				backgroundColor='rgba(0, 0, 0, 0.1)' | ||||||
|  | 				transparent={true} | ||||||
|  | 				onRequestClose={dialogModels[dialogComponents.length - 1]?.onDismiss} | ||||||
|  | 			> | ||||||
|  | 				{dialogComponents} | ||||||
|  | 			</Modal> | ||||||
|  | 		</Portal> | ||||||
|  | 	</>; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default DialogManager; | ||||||
| @@ -33,14 +33,6 @@ const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogSize) | |||||||
| 		const maxHeight = size === DialogSize.Large ? windowSize.height : 700; | 		const maxHeight = size === DialogSize.Large ? windowSize.height : 700; | ||||||
|  |  | ||||||
| 		return StyleSheet.create({ | 		return StyleSheet.create({ | ||||||
| 			webView: { |  | ||||||
| 				backgroundColor: 'transparent', |  | ||||||
| 				display: 'flex', |  | ||||||
| 			}, |  | ||||||
| 			webViewContainer: { |  | ||||||
| 				flexGrow: 1, |  | ||||||
| 				flexShrink: 1, |  | ||||||
| 			}, |  | ||||||
| 			closeButtonContainer: { | 			closeButtonContainer: { | ||||||
| 				flexDirection: 'row', | 				flexDirection: 'row', | ||||||
| 				justifyContent: 'flex-end', | 				justifyContent: 'flex-end', | ||||||
|   | |||||||
| @@ -115,6 +115,7 @@ class Dropdown extends Component<DropdownProps, DropdownState> { | |||||||
| 		const itemWrapperStyle: ViewStyle = { | 		const itemWrapperStyle: ViewStyle = { | ||||||
| 			...(this.props.itemWrapperStyle ? this.props.itemWrapperStyle : {}), | 			...(this.props.itemWrapperStyle ? this.props.itemWrapperStyle : {}), | ||||||
| 			flex: 1, | 			flex: 1, | ||||||
|  | 			flexBasis: 'auto', | ||||||
| 			justifyContent: 'center', | 			justifyContent: 'center', | ||||||
| 			height: itemHeight, | 			height: itemHeight, | ||||||
| 			paddingLeft: 20, | 			paddingLeft: 20, | ||||||
| @@ -197,6 +198,7 @@ class Dropdown extends Component<DropdownProps, DropdownState> { | |||||||
| 						style={headerWrapperStyle} | 						style={headerWrapperStyle} | ||||||
| 						disabled={this.props.disabled} | 						disabled={this.props.disabled} | ||||||
| 						onPress={this.onOpenList} | 						onPress={this.onOpenList} | ||||||
|  | 						role='button' | ||||||
| 					> | 					> | ||||||
| 						<Text ellipsizeMode="tail" numberOfLines={1} style={headerStyle}> | 						<Text ellipsizeMode="tail" numberOfLines={1} style={headerStyle}> | ||||||
| 							{headerLabel} | 							{headerLabel} | ||||||
| @@ -215,6 +217,7 @@ class Dropdown extends Component<DropdownProps, DropdownState> { | |||||||
| 					<TouchableWithoutFeedback | 					<TouchableWithoutFeedback | ||||||
| 						accessibilityElementsHidden={true} | 						accessibilityElementsHidden={true} | ||||||
| 						importantForAccessibility='no-hide-descendants' | 						importantForAccessibility='no-hide-descendants' | ||||||
|  | 						aria-hidden={true} | ||||||
| 						onPress={this.onCloseList} | 						onPress={this.onCloseList} | ||||||
| 						style={backgroundCloseButtonStyle} | 						style={backgroundCloseButtonStyle} | ||||||
| 					> | 					> | ||||||
|   | |||||||
| @@ -15,8 +15,6 @@ import { Props, WebViewControl } from './types'; | |||||||
|  |  | ||||||
| const logger = Logger.create('ExtendedWebView'); | const logger = Logger.create('ExtendedWebView'); | ||||||
|  |  | ||||||
| export { WebViewControl, Props }; |  | ||||||
|  |  | ||||||
| const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => { | const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => { | ||||||
| 	const webviewRef = useRef(null); | 	const webviewRef = useRef(null); | ||||||
| 	const [source, setSource] = useState<WebViewSource|undefined>(undefined); | 	const [source, setSource] = useState<WebViewSource|undefined>(undefined); | ||||||
|   | |||||||
							
								
								
									
										176
									
								
								packages/app-mobile/components/ExtendedWebView/index.web.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								packages/app-mobile/components/ExtendedWebView/index.web.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,176 @@ | |||||||
|  | import * as React from 'react'; | ||||||
|  |  | ||||||
|  | import { | ||||||
|  | 	forwardRef, Ref, useEffect, useImperativeHandle, useRef, useState, | ||||||
|  | } from 'react'; | ||||||
|  | import { Props, WebViewControl } from './types'; | ||||||
|  |  | ||||||
|  | import { View, ViewStyle } from 'react-native'; | ||||||
|  | import makeSandboxedIframe from '@joplin/lib/utils/dom/makeSandboxedIframe'; | ||||||
|  | import Logger from '@joplin/utils/Logger'; | ||||||
|  |  | ||||||
|  | const logger = Logger.create('ExtendedWebView'); | ||||||
|  |  | ||||||
|  | // At present, react-native-webview doesn't support web. As such, ExtendedWebView.web.tsx | ||||||
|  | // uses an iframe when running on web. | ||||||
|  |  | ||||||
|  | const iframeContainerStyles = { height: '100%', width: '100%' }; | ||||||
|  | const wrapperStyle: ViewStyle = { height: '100%', width: '100%', flex: 1 }; | ||||||
|  |  | ||||||
|  | const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => { | ||||||
|  | 	const iframeRef = useRef<HTMLIFrameElement|null>(null); | ||||||
|  |  | ||||||
|  | 	useImperativeHandle(ref, (): WebViewControl => { | ||||||
|  | 		return { | ||||||
|  | 			injectJS(js: string) { | ||||||
|  | 				if (!iframeRef.current) { | ||||||
|  | 					logger.warn(`WebView(${props.webviewInstanceId}): Tried to inject JavaScript after the iframe has unloaded.`); | ||||||
|  | 					return; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				// react-native-webview doesn't seem to show a warning in the case where JavaScript | ||||||
|  | 				// is injected before the first page loads. | ||||||
|  | 				if (!iframeRef.current.contentWindow) { | ||||||
|  | 					return; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				iframeRef.current.contentWindow.postMessage({ | ||||||
|  | 					injectJs: js, | ||||||
|  | 				}, '*'); | ||||||
|  | 			}, | ||||||
|  | 			postMessage(message: unknown) { | ||||||
|  | 				if (!iframeRef.current || !iframeRef.current.contentWindow) { | ||||||
|  | 					logger.warn(`WebView(${props.webviewInstanceId}): Tried to post a message to an unloaded iframe.`); | ||||||
|  | 					return; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				iframeRef.current.contentWindow.postMessage({ | ||||||
|  | 					postMessage: message, | ||||||
|  | 				}, '*'); | ||||||
|  | 			}, | ||||||
|  | 		}; | ||||||
|  | 	}, [props.webviewInstanceId]); | ||||||
|  |  | ||||||
|  | 	const [containerElement, setContainerElement] = useState<HTMLDivElement>(); | ||||||
|  | 	const containerRef = useRef(containerElement); | ||||||
|  | 	containerRef.current = containerElement; | ||||||
|  |  | ||||||
|  | 	const onMessageRef = useRef(props.onMessage); | ||||||
|  | 	onMessageRef.current = props.onMessage; | ||||||
|  | 	const onLoadEndRef = useRef(props.onLoadEnd); | ||||||
|  | 	onLoadEndRef.current = props.onLoadEnd; | ||||||
|  | 	const onLoadStartRef = useRef(props.onLoadStart); | ||||||
|  | 	onLoadStartRef.current = props.onLoadStart; | ||||||
|  |  | ||||||
|  | 	// Don't re-load when injected JS changes. This should match the behavior of the native webview. | ||||||
|  | 	const injectedJavaScriptRef = useRef(props.injectedJavaScript); | ||||||
|  | 	injectedJavaScriptRef.current = props.injectedJavaScript; | ||||||
|  |  | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		const headHtml = ` | ||||||
|  | 			<meta name="viewport" content="width=device-width,initial-scale=1.0"/> | ||||||
|  | 			<meta charset="utf-8"/> | ||||||
|  | 			<!-- Open links in a new window by default --> | ||||||
|  | 			<base target="_blank"/> | ||||||
|  | 		`; | ||||||
|  |  | ||||||
|  | 		const scripts = [ | ||||||
|  | 			` | ||||||
|  | 				window.ReactNativeWebView = { | ||||||
|  | 					postMessage: (message) => { | ||||||
|  | 						parent.postMessage(message, '*'); | ||||||
|  | 					}, | ||||||
|  | 					supportsNonStringMessages: true, | ||||||
|  | 				}; | ||||||
|  |  | ||||||
|  | 				window.addEventListener('message', (event) => { | ||||||
|  | 					if (event.source !== parent || event.origin === 'react-native') { | ||||||
|  | 						return; | ||||||
|  | 					} | ||||||
|  |  | ||||||
|  | 					if (event.data.postMessage) { | ||||||
|  | 						window.dispatchEvent( | ||||||
|  | 							new MessageEvent( | ||||||
|  | 								'message', | ||||||
|  | 								{ | ||||||
|  | 									data: event.data.postMessage, | ||||||
|  | 									origin: 'react-native' | ||||||
|  | 								}, | ||||||
|  | 							), | ||||||
|  | 						); | ||||||
|  | 					} else if (event.data.injectJs) { | ||||||
|  | 						eval('(() => { ' + event.data.injectJs + ' })()'); | ||||||
|  | 					} | ||||||
|  | 				}); | ||||||
|  | 			`, | ||||||
|  | 			injectedJavaScriptRef.current, | ||||||
|  | 		]; | ||||||
|  |  | ||||||
|  | 		const { iframe } = makeSandboxedIframe({ | ||||||
|  | 			bodyHtml: props.html, | ||||||
|  | 			headHtml: headHtml, | ||||||
|  | 			scripts, | ||||||
|  |  | ||||||
|  | 			// allow-popups-to-escape-sandbox: Allows PDF previews to work on target="_blank" links. | ||||||
|  | 			// allow-popups: Allows links to open in a new tab. | ||||||
|  | 			permissions: 'allow-scripts allow-modals allow-popups allow-popups-to-escape-sandbox', | ||||||
|  | 			allow: 'clipboard-write=(self) fullscreen=(self) autoplay=(self) local-fonts=* encrypted-media=*', | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		if (containerRef.current) { | ||||||
|  | 			containerRef.current.replaceChildren(iframe); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		iframeRef.current = iframe; | ||||||
|  |  | ||||||
|  | 		iframe.style.height = '100%'; | ||||||
|  | 		iframe.style.width = '100%'; | ||||||
|  | 		iframe.style.border = 'none'; | ||||||
|  |  | ||||||
|  | 		const messageListener = (event: MessageEvent) => { | ||||||
|  | 			if (event.source !== iframe.contentWindow) { | ||||||
|  | 				return; | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			onMessageRef.current?.({ nativeEvent: { data: event.data } }); | ||||||
|  | 		}; | ||||||
|  | 		window.addEventListener('message', messageListener); | ||||||
|  | 		if (!iframe.loading) { | ||||||
|  | 			onLoadStartRef.current?.(); | ||||||
|  | 			onLoadEndRef.current?.(); | ||||||
|  | 		} else { | ||||||
|  | 			iframe.onload = () => onLoadEndRef.current?.(); | ||||||
|  | 			iframe.onloadstart = () => onLoadStartRef.current?.(); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		return () => { | ||||||
|  | 			window.removeEventListener('message', messageListener); | ||||||
|  |  | ||||||
|  | 			if (iframeRef.current.parentElement) { | ||||||
|  | 				iframeRef.current.remove(); | ||||||
|  | 			} | ||||||
|  | 			iframeRef.current = null; | ||||||
|  | 		}; | ||||||
|  | 	}, [props.html]); | ||||||
|  |  | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		if (!iframeRef.current || !containerElement) return; | ||||||
|  | 		if (iframeRef.current.parentElement) { | ||||||
|  | 			iframeRef.current.remove(); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		containerElement.replaceChildren(iframeRef.current); | ||||||
|  | 	}, [containerElement]); | ||||||
|  |  | ||||||
|  | 	return ( | ||||||
|  | 		<View style={[wrapperStyle, props.style]}> | ||||||
|  | 			<div | ||||||
|  | 				ref={setContainerElement} | ||||||
|  | 				className='iframe-container' | ||||||
|  | 				style={iframeContainerStyles} | ||||||
|  | 			></div> | ||||||
|  | 		</View> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default forwardRef(ExtendedWebView); | ||||||
| @@ -6,9 +6,10 @@ import * as React from 'react'; | |||||||
| import { themeStyle } from '@joplin/lib/theme'; | import { themeStyle } from '@joplin/lib/theme'; | ||||||
| import { Theme } from '@joplin/lib/themes/type'; | import { Theme } from '@joplin/lib/themes/type'; | ||||||
| import { useState, useMemo, useCallback, useRef } from 'react'; | import { useState, useMemo, useCallback, useRef } from 'react'; | ||||||
| import { View, Text, Pressable, ViewStyle, StyleSheet, LayoutChangeEvent, LayoutRectangle, Animated, AccessibilityState, AccessibilityRole, TextStyle } from 'react-native'; | import { Text, Pressable, ViewStyle, StyleSheet, LayoutChangeEvent, LayoutRectangle, Animated, AccessibilityState, AccessibilityRole, TextStyle, GestureResponderEvent, Platform } from 'react-native'; | ||||||
| import { Menu, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu'; | import { Menu, MenuOptions, MenuTrigger, renderers } from 'react-native-popup-menu'; | ||||||
| import Icon from './Icon'; | import Icon from './Icon'; | ||||||
|  | import AccessibleView from './accessibility/AccessibleView'; | ||||||
|  |  | ||||||
| type ButtonClickListener = ()=> void; | type ButtonClickListener = ()=> void; | ||||||
| interface ButtonProps { | interface ButtonProps { | ||||||
| @@ -22,6 +23,10 @@ interface ButtonProps { | |||||||
|  |  | ||||||
| 	themeId: number; | 	themeId: number; | ||||||
|  |  | ||||||
|  | 	// (web only) On web, touching buttons can cause the on-screen keyboard to be dismissed. | ||||||
|  | 	// Setting preventKeyboardDismiss overrides this behavior. | ||||||
|  | 	preventKeyboardDismiss?: boolean; | ||||||
|  |  | ||||||
| 	containerStyle?: ViewStyle; | 	containerStyle?: ViewStyle; | ||||||
| 	contentWrapperStyle?: ViewStyle; | 	contentWrapperStyle?: ViewStyle; | ||||||
|  |  | ||||||
| @@ -74,6 +79,10 @@ const IconButton = (props: ButtonProps) => { | |||||||
| 		setButtonLayout({ ...layoutEvt }); | 		setButtonLayout({ ...layoutEvt }); | ||||||
| 	}, []); | 	}, []); | ||||||
|  |  | ||||||
|  | 	const { onTouchStart, onTouchMove, onTouchEnd } = usePreventKeyboardDismissTouchListeners( | ||||||
|  | 		props.preventKeyboardDismiss, props.onPress, props.disabled, | ||||||
|  | 	); | ||||||
|  |  | ||||||
| 	const button = ( | 	const button = ( | ||||||
| 		<Pressable | 		<Pressable | ||||||
| 			onPress={props.onPress} | 			onPress={props.onPress} | ||||||
| @@ -81,6 +90,10 @@ const IconButton = (props: ButtonProps) => { | |||||||
| 			onPressIn={onPressIn} | 			onPressIn={onPressIn} | ||||||
| 			onPressOut={onPressOut} | 			onPressOut={onPressOut} | ||||||
|  |  | ||||||
|  | 			onTouchStart={onTouchStart} | ||||||
|  | 			onTouchMove={onTouchMove} | ||||||
|  | 			onTouchEnd={onTouchEnd} | ||||||
|  |  | ||||||
| 			style={ props.containerStyle } | 			style={ props.containerStyle } | ||||||
|  |  | ||||||
| 			disabled={ props.disabled ?? false } | 			disabled={ props.disabled ?? false } | ||||||
| @@ -108,14 +121,11 @@ const IconButton = (props: ButtonProps) => { | |||||||
| 		if (!props.description) return null; | 		if (!props.description) return null; | ||||||
|  |  | ||||||
| 		return ( | 		return ( | ||||||
| 			<View | 			<AccessibleView | ||||||
| 				// Any information given by the tooltip should also be provided via | 				// Any information given by the tooltip should also be provided via | ||||||
| 				// [accessibilityLabel]/[accessibilityHint]. As such, we can hide the tooltip | 				// [accessibilityLabel]/[accessibilityHint]. As such, we can hide the tooltip | ||||||
| 				// from the screen reader. | 				// from the screen reader. | ||||||
| 				// On Android: | 				inert={true} | ||||||
| 				importantForAccessibility='no-hide-descendants' |  | ||||||
| 				// On iOS: |  | ||||||
| 				accessibilityElementsHidden={true} |  | ||||||
|  |  | ||||||
| 				// Position the menu beneath the button so the tooltip appears in the | 				// Position the menu beneath the button so the tooltip appears in the | ||||||
| 				// correct location. | 				// correct location. | ||||||
| @@ -150,7 +160,7 @@ const IconButton = (props: ButtonProps) => { | |||||||
| 						</Text> | 						</Text> | ||||||
| 					</MenuOptions> | 					</MenuOptions> | ||||||
| 				</Menu> | 				</Menu> | ||||||
| 			</View> | 			</AccessibleView> | ||||||
| 		); | 		); | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| @@ -181,4 +191,40 @@ const useTooltipStyles = (themeId: number) => { | |||||||
| 	}, [themeId]); | 	}, [themeId]); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | // On web, by default, pressing buttons defocuses the active edit control, dismissing the | ||||||
|  | // virtual keyboard. This hook creates listeners that optionally prevent the keyboard from dismissing. | ||||||
|  | const usePreventKeyboardDismissTouchListeners = (preventKeyboardDismiss: boolean, onPress: ()=> void, disabled: boolean) => { | ||||||
|  | 	const touchStartPointRef = useRef<[number, number]>(); | ||||||
|  | 	const isTapRef = useRef<boolean>(); | ||||||
|  | 	const onTouchStart = useCallback((event: GestureResponderEvent) => { | ||||||
|  | 		if (Platform.OS === 'web' && preventKeyboardDismiss) { | ||||||
|  | 			const touch = event.nativeEvent.touches[0]; | ||||||
|  | 			touchStartPointRef.current = [touch?.pageX, touch?.pageY]; | ||||||
|  | 			isTapRef.current = true; | ||||||
|  | 		} | ||||||
|  | 	}, [preventKeyboardDismiss]); | ||||||
|  |  | ||||||
|  | 	const onTouchMove = useCallback((event: GestureResponderEvent) => { | ||||||
|  | 		if (Platform.OS === 'web' && preventKeyboardDismiss && isTapRef.current) { | ||||||
|  | 			// Update isTapRef onTouchMove, rather than onTouchEnd -- the final | ||||||
|  | 			// touch position is unavailable in onTouchEnd on some devices. | ||||||
|  | 			const touch = event.nativeEvent.touches[0]; | ||||||
|  | 			const dx = touch?.pageX - touchStartPointRef.current[0]; | ||||||
|  | 			const dy = touch?.pageY - touchStartPointRef.current[1]; | ||||||
|  | 			isTapRef.current = Math.hypot(dx, dy) < 15; | ||||||
|  | 		} | ||||||
|  | 	}, [preventKeyboardDismiss]); | ||||||
|  |  | ||||||
|  | 	const onTouchEnd = useCallback((event: GestureResponderEvent) => { | ||||||
|  | 		if (Platform.OS === 'web' && preventKeyboardDismiss) { | ||||||
|  | 			if (isTapRef.current && !disabled) { | ||||||
|  | 				event.preventDefault(); | ||||||
|  | 				onPress(); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	}, [onPress, disabled, preventKeyboardDismiss]); | ||||||
|  |  | ||||||
|  | 	return { onTouchStart, onTouchMove, onTouchEnd }; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export default IconButton; | export default IconButton; | ||||||
|   | |||||||
| @@ -27,6 +27,7 @@ const useStyles = (backgroundColor?: string) => { | |||||||
| 				...backgroundPadding, | 				...backgroundPadding, | ||||||
| 				backgroundColor, | 				backgroundColor, | ||||||
| 				flexGrow: 1, | 				flexGrow: 1, | ||||||
|  | 				flexShrink: 1, | ||||||
| 			}, | 			}, | ||||||
| 		}); | 		}); | ||||||
| 	}, [isLandscape, backgroundColor]); | 	}, [isLandscape, backgroundColor]); | ||||||
|   | |||||||
| @@ -14,12 +14,13 @@ import { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnM | |||||||
| import Resource from '@joplin/lib/models/Resource'; | import Resource from '@joplin/lib/models/Resource'; | ||||||
| import shim from '@joplin/lib/shim'; | import shim from '@joplin/lib/shim'; | ||||||
| import Note from '@joplin/lib/models/Note'; | import Note from '@joplin/lib/models/Note'; | ||||||
|  | import { ResourceInfo } from './hooks/useRerenderHandler'; | ||||||
| import getWebViewDomById from '../../utils/testing/getWebViewDomById'; | import getWebViewDomById from '../../utils/testing/getWebViewDomById'; | ||||||
|  |  | ||||||
| interface WrapperProps { | interface WrapperProps { | ||||||
| 	noteBody: string; | 	noteBody: string; | ||||||
| 	highlightedKeywords?: string[]; | 	highlightedKeywords?: string[]; | ||||||
| 	noteResources?: unknown; | 	noteResources?: Record<string, ResourceInfo>; | ||||||
| 	onJoplinLinkClick?: HandleMessageCallback; | 	onJoplinLinkClick?: HandleMessageCallback; | ||||||
| 	onScroll?: (percent: number)=> void; | 	onScroll?: (percent: number)=> void; | ||||||
| 	onMarkForDownload?: OnMarkForDownloadCallback; | 	onMarkForDownload?: OnMarkForDownloadCallback; | ||||||
|   | |||||||
| @@ -4,11 +4,12 @@ import useOnMessage, { HandleMessageCallback, OnMarkForDownloadCallback } from ' | |||||||
| import { useRef, useCallback, useState, useMemo } from 'react'; | import { useRef, useCallback, useState, useMemo } from 'react'; | ||||||
| import { View, ViewStyle } from 'react-native'; | import { View, ViewStyle } from 'react-native'; | ||||||
| import BackButtonDialogBox from '../BackButtonDialogBox'; | import BackButtonDialogBox from '../BackButtonDialogBox'; | ||||||
| import ExtendedWebView, { WebViewControl } from '../ExtendedWebView'; | import ExtendedWebView from '../ExtendedWebView'; | ||||||
|  | import { WebViewControl } from '../ExtendedWebView/types'; | ||||||
| import useOnResourceLongPress from './hooks/useOnResourceLongPress'; | import useOnResourceLongPress from './hooks/useOnResourceLongPress'; | ||||||
| import useRenderer from './hooks/useRenderer'; | import useRenderer from './hooks/useRenderer'; | ||||||
| import { OnWebViewMessageHandler } from './types'; | import { OnWebViewMessageHandler } from './types'; | ||||||
| import useRerenderHandler from './hooks/useRerenderHandler'; | import useRerenderHandler, { ResourceInfo } from './hooks/useRerenderHandler'; | ||||||
| import useSource from './hooks/useSource'; | import useSource from './hooks/useSource'; | ||||||
| import Setting from '@joplin/lib/models/Setting'; | import Setting from '@joplin/lib/models/Setting'; | ||||||
| import uuid from '@joplin/lib/uuid'; | import uuid from '@joplin/lib/uuid'; | ||||||
| @@ -22,8 +23,7 @@ interface Props { | |||||||
| 	noteBody: string; | 	noteBody: string; | ||||||
| 	noteMarkupLanguage: MarkupLanguage; | 	noteMarkupLanguage: MarkupLanguage; | ||||||
| 	highlightedKeywords: string[]; | 	highlightedKeywords: string[]; | ||||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | 	noteResources: Record<string, ResourceInfo>; | ||||||
| 	noteResources: any; |  | ||||||
| 	paddingBottom: number; | 	paddingBottom: number; | ||||||
| 	initialScroll: number|null; | 	initialScroll: number|null; | ||||||
| 	noteHash: string; | 	noteHash: string; | ||||||
|   | |||||||
| @@ -12,6 +12,7 @@ const defaultRendererSettings: RendererSettings = { | |||||||
| 	codeTheme: 'atom-one-light.css', | 	codeTheme: 'atom-one-light.css', | ||||||
| 	noteHash: '', | 	noteHash: '', | ||||||
| 	initialScroll: 0, | 	initialScroll: 0, | ||||||
|  | 	readAssetBlob: async (_path: string)=>new Blob(), | ||||||
|  |  | ||||||
| 	createEditPopupSyntax: '', | 	createEditPopupSyntax: '', | ||||||
| 	destroyEditPopupSyntax: '', | 	destroyEditPopupSyntax: '', | ||||||
| @@ -28,6 +29,7 @@ const makeRenderer = (options: Partial<RendererSetupOptions>) => { | |||||||
| 			resourceDir: Setting.value('resourceDir'), | 			resourceDir: Setting.value('resourceDir'), | ||||||
| 			resourceDownloadMode: 'auto', | 			resourceDownloadMode: 'auto', | ||||||
| 		}, | 		}, | ||||||
|  | 		useTransferredFiles: false, | ||||||
| 		fsDriver: shim.fsDriver(), | 		fsDriver: shim.fsDriver(), | ||||||
| 		pluginOptions: {}, | 		pluginOptions: {}, | ||||||
| 	}; | 	}; | ||||||
|   | |||||||
| @@ -12,6 +12,11 @@ export interface RendererSetupOptions { | |||||||
| 		resourceDir: string; | 		resourceDir: string; | ||||||
| 		resourceDownloadMode: string; | 		resourceDownloadMode: string; | ||||||
| 	}; | 	}; | ||||||
|  | 	// True if asset and resource files should be transferred to the WebView before rendering. | ||||||
|  | 	// This must be true on web, where asset and resource files are virtual and can't be accessed | ||||||
|  | 	// without transferring. | ||||||
|  | 	useTransferredFiles: boolean; | ||||||
|  |  | ||||||
| 	fsDriver: RendererFsDriver; | 	fsDriver: RendererFsDriver; | ||||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||||
| 	pluginOptions: Record<string, any>; | 	pluginOptions: Record<string, any>; | ||||||
| @@ -33,6 +38,7 @@ export interface RendererSettings { | |||||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||||
| 	pluginSettings: Record<string, any>; | 	pluginSettings: Record<string, any>; | ||||||
| 	requestPluginSetting: (pluginId: string, settingKey: string)=> void; | 	requestPluginSetting: (pluginId: string, settingKey: string)=> void; | ||||||
|  | 	readAssetBlob: (assetPath: string)=> Promise<Blob>; | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface MarkupRecord { | export interface MarkupRecord { | ||||||
| @@ -45,6 +51,7 @@ export default class Renderer { | |||||||
| 	private lastSettings: RendererSettings|null = null; | 	private lastSettings: RendererSettings|null = null; | ||||||
| 	private extraContentScripts: ExtraContentScript[] = []; | 	private extraContentScripts: ExtraContentScript[] = []; | ||||||
| 	private lastRenderMarkup: MarkupRecord|null = null; | 	private lastRenderMarkup: MarkupRecord|null = null; | ||||||
|  | 	private resourcePathOverrides: Record<string, string> = Object.create(null); | ||||||
|  |  | ||||||
| 	public constructor(private setupOptions: RendererSetupOptions) { | 	public constructor(private setupOptions: RendererSetupOptions) { | ||||||
| 		this.recreateMarkupToHtml(); | 		this.recreateMarkupToHtml(); | ||||||
| @@ -61,6 +68,18 @@ export default class Renderer { | |||||||
| 		}); | 		}); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	// Intended for web, where resources can't be linked to normally. | ||||||
|  | 	public async setResourceFile(id: string, file: Blob) { | ||||||
|  | 		this.resourcePathOverrides[id] = URL.createObjectURL(file); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public getResourcePathOverride(resourceId: string) { | ||||||
|  | 		if (Object.prototype.hasOwnProperty.call(this.resourcePathOverrides, resourceId)) { | ||||||
|  | 			return this.resourcePathOverrides[resourceId]; | ||||||
|  | 		} | ||||||
|  | 		return null; | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	public async setExtraContentScriptsAndRerender( | 	public async setExtraContentScriptsAndRerender( | ||||||
| 		extraContentScripts: ExtraContentScriptSource[], | 		extraContentScripts: ExtraContentScriptSource[], | ||||||
| 	) { | 	) { | ||||||
| @@ -108,6 +127,7 @@ export default class Renderer { | |||||||
| 			editPopupFiletypes: ['image/svg+xml'], | 			editPopupFiletypes: ['image/svg+xml'], | ||||||
| 			createEditPopupSyntax: settings.createEditPopupSyntax, | 			createEditPopupSyntax: settings.createEditPopupSyntax, | ||||||
| 			destroyEditPopupSyntax: settings.destroyEditPopupSyntax, | 			destroyEditPopupSyntax: settings.destroyEditPopupSyntax, | ||||||
|  | 			itemIdToUrl: this.setupOptions.useTransferredFiles ? (id: string) => this.getResourcePathOverride(id) : undefined, | ||||||
|  |  | ||||||
| 			settingValue: (pluginId: string, settingName: string) => { | 			settingValue: (pluginId: string, settingName: string) => { | ||||||
| 				const settingKey = `${pluginId}.${settingName}`; | 				const settingKey = `${pluginId}.${settingName}`; | ||||||
| @@ -151,7 +171,17 @@ export default class Renderer { | |||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		contentContainer.innerHTML = html; | 		contentContainer.innerHTML = html; | ||||||
| 		addPluginAssets(pluginAssets); |  | ||||||
|  | 		// Adding plugin assets can be slow -- run it asynchronously. | ||||||
|  | 		void (async () => { | ||||||
|  | 			await addPluginAssets(pluginAssets, { | ||||||
|  | 				inlineAssets: this.setupOptions.useTransferredFiles, | ||||||
|  | 				readAssetBlob: settings.readAssetBlob, | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			// Some plugins require this event to be dispatched just after being added. | ||||||
|  | 			document.dispatchEvent(new Event('joplin-noteDidUpdate')); | ||||||
|  | 		})(); | ||||||
|  |  | ||||||
| 		this.afterRender(settings); | 		this.afterRender(settings); | ||||||
| 	} | 	} | ||||||
| @@ -187,9 +217,6 @@ export default class Renderer { | |||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 		}, 10); | 		}, 10); | ||||||
|  |  | ||||||
| 		// Used by some parts of the renderer (e.g. to rerender mermaid.js diagrams). |  | ||||||
| 		document.dispatchEvent(new Event('joplin-noteDidUpdate')); |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	public clearCache(markupLanguage: MarkupLanguage) { | 	public clearCache(markupLanguage: MarkupLanguage) { | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ export interface RendererWebViewOptions { | |||||||
| 		resourceDir: string; | 		resourceDir: string; | ||||||
| 		resourceDownloadMode: string; | 		resourceDownloadMode: string; | ||||||
| 	}; | 	}; | ||||||
|  | 	useTransferredFiles: boolean; | ||||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||||
| 	pluginOptions: Record<string, any>; | 	pluginOptions: Record<string, any>; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,21 +1,79 @@ | |||||||
| import { RenderResultPluginAsset } from '@joplin/renderer/types'; | import { RenderResultPluginAsset } from '@joplin/renderer/types'; | ||||||
|  | import { join, dirname } from 'path'; | ||||||
|  |  | ||||||
| type PluginAssetRecord = { | type PluginAssetRecord = { | ||||||
| 	element: HTMLElement; | 	element: HTMLElement; | ||||||
| }; | }; | ||||||
| const pluginAssetsAdded_: Record<string, PluginAssetRecord> = {}; | const pluginAssetsAdded_: Record<string, PluginAssetRecord> = {}; | ||||||
|  |  | ||||||
|  | const assetUrlMap_: Map<string, ()=> Promise<string>> = new Map(); | ||||||
|  |  | ||||||
|  | // Some resources (e.g. CSS) reference other resources with relative paths. On web, due to sandboxing | ||||||
|  | // and how plugin assets are stored, these links need to be rewritten. | ||||||
|  | const rewriteInternalAssetLinks = async (asset: RenderResultPluginAsset, content: string) => { | ||||||
|  | 	if (asset.mime === 'text/css') { | ||||||
|  | 		const urlRegex = /(url\()([^)]+)(\))/g; | ||||||
|  |  | ||||||
|  | 		// Converting resource paths to URLs is async. To handle this, we do two passes. | ||||||
|  | 		// In the first, the original URLs are collected. In the second, the URLs are replaced. | ||||||
|  | 		const replacements: [string, string][] = []; | ||||||
|  | 		let replacementIndex = 0; | ||||||
|  | 		content = content.replace(urlRegex, (match, _group1, url, _group3) => { | ||||||
|  | 			const target = join(dirname(asset.path), url); | ||||||
|  | 			if (!assetUrlMap_.has(target)) return match; | ||||||
|  | 			const replaceString = `<<to-replace-with-url-${replacementIndex++}>>`; | ||||||
|  | 			replacements.push([replaceString, target]); | ||||||
|  | 			return `url(${replaceString})`; | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		for (const [replacement, path] of replacements) { | ||||||
|  | 			const url = await assetUrlMap_.get(path)(); | ||||||
|  | 			content = content.replace(replacement, url); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		return content; | ||||||
|  | 	} else { | ||||||
|  | 		return content; | ||||||
|  | 	} | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | interface Options { | ||||||
|  | 	inlineAssets: boolean; | ||||||
|  | 	readAssetBlob?(path: string): Promise<Blob>; | ||||||
|  | } | ||||||
|  |  | ||||||
| // Note that this function keeps track of what's been added so as not to | // Note that this function keeps track of what's been added so as not to | ||||||
| // add the same CSS files multiple times. | // add the same CSS files multiple times. | ||||||
| // | const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Options) => { | ||||||
| // Shared with app-desktop/gui-note-viewer. |  | ||||||
| // |  | ||||||
| // TODO: If possible, refactor such that this function is not duplicated. |  | ||||||
| const addPluginAssets = (assets: RenderResultPluginAsset[]) => { |  | ||||||
| 	if (!assets) return; | 	if (!assets) return; | ||||||
|  |  | ||||||
| 	const pluginAssetsContainer = document.getElementById('joplin-container-pluginAssetsContainer'); | 	const pluginAssetsContainer = document.getElementById('joplin-container-pluginAssetsContainer'); | ||||||
|  |  | ||||||
|  | 	const prepareAssetBlobUrls = () => { | ||||||
|  | 		for (const asset of assets) { | ||||||
|  | 			const path = asset.path; | ||||||
|  | 			if (!assetUrlMap_.has(path)) { | ||||||
|  | 				// Fetching assets can be expensive -- avoid refetching assets where possible. | ||||||
|  | 				let url: string|null = null; | ||||||
|  | 				assetUrlMap_.set(path, async () => { | ||||||
|  | 					if (url !== null) return url; | ||||||
|  |  | ||||||
|  | 					const blob = await options.readAssetBlob(path); | ||||||
|  | 					if (!blob) { | ||||||
|  | 						url = ''; | ||||||
|  | 					} else { | ||||||
|  | 						url = URL.createObjectURL(blob); | ||||||
|  | 					} | ||||||
|  | 					return url; | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	if (options.inlineAssets) { | ||||||
|  | 		prepareAssetBlobUrls(); | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	const processedAssetIds = []; | 	const processedAssetIds = []; | ||||||
|  |  | ||||||
| 	for (let i = 0; i < assets.length; i++) { | 	for (let i = 0; i < assets.length; i++) { | ||||||
| @@ -34,14 +92,33 @@ const addPluginAssets = (assets: RenderResultPluginAsset[]) => { | |||||||
|  |  | ||||||
| 		let element = null; | 		let element = null; | ||||||
|  |  | ||||||
| 		if (asset.mime === 'application/javascript') { | 		if (options.inlineAssets) { | ||||||
| 			element = document.createElement('script'); | 			if (asset.mime === 'application/javascript') { | ||||||
| 			element.src = encodedPath; | 				element = document.createElement('script'); | ||||||
| 			pluginAssetsContainer.appendChild(element); | 			} else if (asset.mime === 'text/css') { | ||||||
| 		} else if (asset.mime === 'text/css') { | 				element = document.createElement('style'); | ||||||
| 			element = document.createElement('link'); | 			} | ||||||
| 			element.rel = 'stylesheet'; |  | ||||||
| 			element.href = encodedPath; | 			if (element) { | ||||||
|  | 				const blob = await options.readAssetBlob(asset.path); | ||||||
|  | 				if (blob) { | ||||||
|  | 					const assetContent = await blob.text(); | ||||||
|  | 					element.appendChild( | ||||||
|  | 						document.createTextNode(await rewriteInternalAssetLinks(asset, assetContent)), | ||||||
|  | 					); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} else { | ||||||
|  | 			if (asset.mime === 'application/javascript') { | ||||||
|  | 				element = document.createElement('script'); | ||||||
|  | 				element.src = encodedPath; | ||||||
|  | 			} else if (asset.mime === 'text/css') { | ||||||
|  | 				element = document.createElement('link'); | ||||||
|  | 				element.rel = 'stylesheet'; | ||||||
|  | 				element.href = encodedPath; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		if (element) { | ||||||
| 			pluginAssetsContainer.appendChild(element); | 			pluginAssetsContainer.appendChild(element); | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,11 +5,22 @@ import { Theme } from '@joplin/lib/themes/type'; | |||||||
| import { useMemo } from 'react'; | import { useMemo } from 'react'; | ||||||
| import { extname } from 'path'; | import { extname } from 'path'; | ||||||
| import shim from '@joplin/lib/shim'; | import shim from '@joplin/lib/shim'; | ||||||
|  | import { Platform } from 'react-native'; | ||||||
| const Icon = require('react-native-vector-icons/Ionicons').default; | const Icon = require('react-native-vector-icons/Ionicons').default; | ||||||
|  |  | ||||||
| export const editPopupClass = 'joplin-editPopup'; | export const editPopupClass = 'joplin-editPopup'; | ||||||
|  |  | ||||||
| const getEditIconSrc = (theme: Theme) => { | const getEditIconSrc = (theme: Theme) => { | ||||||
|  | 	// Use an inline edit icon on web -- getImageSourceSync isn't supported there. | ||||||
|  | 	if (Platform.OS === 'web') { | ||||||
|  | 		const svgData = ` | ||||||
|  | 			<svg viewBox="-103 60 180 180" width="30" height="30" version="1.1" baseProfile="full" xmlns="http://www.w3.org/2000/svg"> | ||||||
|  | 				<path d="m 100,19 c -11.7,0 -21.1,9.5 -21.2,21.2 0,0 42.3,0 42.3,0 0,-11.7 -9.5,-21.2 -21.2,-21.2 z M 79,43 v 143 l 21.3,26.4 21,-26.5 V 42.8 Z" style="transform: rotate(45deg)" fill=${JSON.stringify(theme.color2)}/> | ||||||
|  | 			</svg> | ||||||
|  | 		`.replace(/[ \t\n]+/, ' '); | ||||||
|  | 		return `data:image/svg+xml;utf8,${encodeURIComponent(svgData)}`; | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	const iconUri = Icon.getImageSourceSync('pencil', 20, theme.color2).uri; | 	const iconUri = Icon.getImageSourceSync('pencil', 20, theme.color2).uri; | ||||||
|  |  | ||||||
| 	// Copy to a location that can be read within a WebView | 	// Copy to a location that can be read within a WebView | ||||||
|   | |||||||
| @@ -1,13 +1,15 @@ | |||||||
| import { useCallback } from 'react'; | import { useCallback } from 'react'; | ||||||
|  |  | ||||||
| const { ToastAndroid } = require('react-native'); |  | ||||||
| const { _ } = require('@joplin/lib/locale.js'); | const { _ } = require('@joplin/lib/locale.js'); | ||||||
| import { reg } from '@joplin/lib/registry'; |  | ||||||
| const { dialogs } = require('../../../utils/dialogs.js'); | const { dialogs } = require('../../../utils/dialogs.js'); | ||||||
| import Resource from '@joplin/lib/models/Resource'; | import Resource from '@joplin/lib/models/Resource'; | ||||||
| import { copyToCache } from '../../../utils/ShareUtils'; | import { copyToCache } from '../../../utils/ShareUtils'; | ||||||
| import isEditableResource from '../../NoteEditor/ImageEditor/isEditableResource'; | import isEditableResource from '../../NoteEditor/ImageEditor/isEditableResource'; | ||||||
| const Share = require('react-native-share').default; | import shim from '@joplin/lib/shim'; | ||||||
|  | import shareFile from '../../../utils/shareFile'; | ||||||
|  | import Logger from '@joplin/utils/Logger'; | ||||||
|  |  | ||||||
|  | const logger = Logger.create('useOnResourceLongPress'); | ||||||
|  |  | ||||||
| interface Callbacks { | interface Callbacks { | ||||||
| 	onJoplinLinkClick: (link: string)=> void; | 	onJoplinLinkClick: (link: string)=> void; | ||||||
| @@ -25,7 +27,7 @@ export default function useOnResourceLongPress(callbacks: Callbacks, dialogBoxRe | |||||||
|  |  | ||||||
| 			// Handle the case where it's a long press on a link with no resource | 			// Handle the case where it's a long press on a link with no resource | ||||||
| 			if (!resource) { | 			if (!resource) { | ||||||
| 				reg.logger().warn(`Long-press: Resource with ID ${resourceId} does not exist (may be a note).`); | 				logger.warn(`Long-press: Resource with ID ${resourceId} does not exist (may be a note).`); | ||||||
| 				return; | 				return; | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| @@ -46,19 +48,13 @@ export default function useOnResourceLongPress(callbacks: Callbacks, dialogBoxRe | |||||||
| 				onJoplinLinkClick(`joplin://${resourceId}`); | 				onJoplinLinkClick(`joplin://${resourceId}`); | ||||||
| 			} else if (action === 'share') { | 			} else if (action === 'share') { | ||||||
| 				const fileToShare = await copyToCache(resource); | 				const fileToShare = await copyToCache(resource); | ||||||
|  | 				await shareFile(fileToShare, resource.mime); | ||||||
| 				await Share.open({ |  | ||||||
| 					type: resource.mime, |  | ||||||
| 					filename: resource.title, |  | ||||||
| 					url: `file://${fileToShare}`, |  | ||||||
| 					failOnCancel: false, |  | ||||||
| 				}); |  | ||||||
| 			} else if (action === 'edit') { | 			} else if (action === 'edit') { | ||||||
| 				onRequestEditResource(`edit:${resourceId}`); | 				onRequestEditResource(`edit:${resourceId}`); | ||||||
| 			} | 			} | ||||||
| 		} catch (e) { | 		} catch (e) { | ||||||
| 			reg.logger().error('Could not handle link long press', e); | 			logger.error('Could not handle link long press', e); | ||||||
| 			ToastAndroid.show('An error occurred, check log for details', ToastAndroid.SHORT); | 			void shim.showMessageBox(`An error occurred, check log for details: ${e}`); | ||||||
| 		} | 		} | ||||||
| 	}, [onJoplinLinkClick, onRequestEditResource, dialogBoxRef]); | 	}, [onJoplinLinkClick, onRequestEditResource, dialogBoxRef]); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import { Dispatch, RefObject, SetStateAction, useEffect, useMemo, useRef } from 'react'; | import { Dispatch, RefObject, SetStateAction, useEffect, useMemo, useRef } from 'react'; | ||||||
| import { WebViewControl } from '../../ExtendedWebView'; | import { WebViewControl } from '../../ExtendedWebView/types'; | ||||||
| import { OnScrollCallback, OnWebViewMessageHandler } from '../types'; | import { OnScrollCallback, OnWebViewMessageHandler } from '../types'; | ||||||
| import RNToWebViewMessenger from '../../../utils/ipc/RNToWebViewMessenger'; | import RNToWebViewMessenger from '../../../utils/ipc/RNToWebViewMessenger'; | ||||||
| import { NoteViewerLocalApi, NoteViewerRemoteApi } from '../bundledJs/types'; | import { NoteViewerLocalApi, NoteViewerRemoteApi } from '../bundledJs/types'; | ||||||
|   | |||||||
| @@ -8,6 +8,15 @@ import { useEffect, useState } from 'react'; | |||||||
| import Logger from '@joplin/utils/Logger'; | import Logger from '@joplin/utils/Logger'; | ||||||
| import { ExtraContentScriptSource } from '../bundledJs/types'; | import { ExtraContentScriptSource } from '../bundledJs/types'; | ||||||
| import Setting from '@joplin/lib/models/Setting'; | import Setting from '@joplin/lib/models/Setting'; | ||||||
|  | import shim from '@joplin/lib/shim'; | ||||||
|  | import Resource from '@joplin/lib/models/Resource'; | ||||||
|  | import { ResourceEntity } from '@joplin/lib/services/database/types'; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export interface ResourceInfo { | ||||||
|  | 	localState: unknown; | ||||||
|  | 	item: ResourceEntity; | ||||||
|  | } | ||||||
|  |  | ||||||
| interface Props { | interface Props { | ||||||
| 	renderer: Renderer; | 	renderer: Renderer; | ||||||
| @@ -17,7 +26,7 @@ interface Props { | |||||||
| 	themeId: number; | 	themeId: number; | ||||||
|  |  | ||||||
| 	highlightedKeywords: string[]; | 	highlightedKeywords: string[]; | ||||||
| 	noteResources: string[]; | 	noteResources: Record<string, ResourceInfo>; | ||||||
| 	noteHash: string; | 	noteHash: string; | ||||||
| 	initialScroll: number|undefined; | 	initialScroll: number|undefined; | ||||||
|  |  | ||||||
| @@ -113,6 +122,25 @@ const useRerenderHandler = (props: Props) => { | |||||||
| 		} | 		} | ||||||
| 		let newPluginSettingKeys = pluginSettingKeys; | 		let newPluginSettingKeys = pluginSettingKeys; | ||||||
|  |  | ||||||
|  | 		// On web, resources are virtual files and thus need to be transferred to the WebView. | ||||||
|  | 		if (shim.mobilePlatform() === 'web') { | ||||||
|  | 			for (const [resourceId, resource] of Object.entries(props.noteResources)) { | ||||||
|  | 				try { | ||||||
|  | 					await props.renderer.setResourceFile( | ||||||
|  | 						resourceId, | ||||||
|  | 						await shim.fsDriver().fileAtPath(Resource.fullPath(resource.item)), | ||||||
|  | 					); | ||||||
|  | 				} catch (error) { | ||||||
|  | 					if (error.code !== 'ENOENT') { | ||||||
|  | 						throw error; | ||||||
|  | 					} | ||||||
|  |  | ||||||
|  | 					// This can happen if a resource hasn't been downloaded yet | ||||||
|  | 					logger.warn('Error: Resource file not found (ENOENT)', Resource.fullPath(resource.item), 'for ID', resource.item.id); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
| 		const theme = themeStyle(props.themeId); | 		const theme = themeStyle(props.themeId); | ||||||
| 		const config = { | 		const config = { | ||||||
| 			// We .stringify the theme to avoid a JSON serialization error involving | 			// We .stringify the theme to avoid a JSON serialization error involving | ||||||
| @@ -150,6 +178,11 @@ const useRerenderHandler = (props: Props) => { | |||||||
| 					setPluginSettingKeys(newPluginSettingKeys); | 					setPluginSettingKeys(newPluginSettingKeys); | ||||||
| 				} | 				} | ||||||
| 			}, | 			}, | ||||||
|  | 			readAssetBlob: (assetPath: string) => { | ||||||
|  | 				const assetsDir = `${Setting.value('resourceDir')}/`; | ||||||
|  | 				const path = shim.fsDriver().resolveRelativePathWithinDir(assetsDir, assetPath); | ||||||
|  | 				return shim.fsDriver().fileAtPath(path); | ||||||
|  | 			}, | ||||||
|  |  | ||||||
| 			createEditPopupSyntax, | 			createEditPopupSyntax, | ||||||
| 			destroyEditPopupSyntax, | 			destroyEditPopupSyntax, | ||||||
|   | |||||||
| @@ -3,6 +3,7 @@ import shim from '@joplin/lib/shim'; | |||||||
| import Setting from '@joplin/lib/models/Setting'; | import Setting from '@joplin/lib/models/Setting'; | ||||||
| import { RendererWebViewOptions } from '../bundledJs/types'; | import { RendererWebViewOptions } from '../bundledJs/types'; | ||||||
| import { themeStyle } from '../../global-style'; | import { themeStyle } from '../../global-style'; | ||||||
|  | import { Platform } from 'react-native'; | ||||||
|  |  | ||||||
| const useSource = (tempDirPath: string, themeId: number) => { | const useSource = (tempDirPath: string, themeId: number) => { | ||||||
| 	const injectedJs = useMemo(() => { | 	const injectedJs = useMemo(() => { | ||||||
| @@ -20,6 +21,9 @@ const useSource = (tempDirPath: string, themeId: number) => { | |||||||
| 				resourceDir: Setting.value('resourceDir'), | 				resourceDir: Setting.value('resourceDir'), | ||||||
| 				resourceDownloadMode: Setting.value('sync.resourceDownloadMode'), | 				resourceDownloadMode: Setting.value('sync.resourceDownloadMode'), | ||||||
| 			}, | 			}, | ||||||
|  | 			// Web needs files to be transferred manually, since image SRCs can't reference | ||||||
|  | 			// the Origin Private File System. | ||||||
|  | 			useTransferredFiles: Platform.OS === 'web', | ||||||
| 			pluginOptions, | 			pluginOptions, | ||||||
| 		}; | 		}; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,12 +5,14 @@ import Setting from '@joplin/lib/models/Setting'; | |||||||
| import shim from '@joplin/lib/shim'; | import shim from '@joplin/lib/shim'; | ||||||
| import { themeStyle } from '@joplin/lib/theme'; | import { themeStyle } from '@joplin/lib/theme'; | ||||||
| import { Theme } from '@joplin/lib/themes/type'; | import { Theme } from '@joplin/lib/themes/type'; | ||||||
| import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; | import { MutableRefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; | ||||||
| import { Alert, BackHandler } from 'react-native'; | import { BackHandler, Platform } from 'react-native'; | ||||||
| import ExtendedWebView, { WebViewControl } from '../../ExtendedWebView'; | import ExtendedWebView from '../../ExtendedWebView'; | ||||||
|  | import { WebViewControl } from '../../ExtendedWebView/types'; | ||||||
| import { clearAutosave, writeAutosave } from './autosave'; | import { clearAutosave, writeAutosave } from './autosave'; | ||||||
| import { LocalizedStrings } from './js-draw/types'; | import { LocalizedStrings } from './js-draw/types'; | ||||||
| import VersionInfo from 'react-native-version-info'; | import VersionInfo from 'react-native-version-info'; | ||||||
|  | import { DialogContext } from '../../DialogManager'; | ||||||
| import { OnMessageEvent } from '../../ExtendedWebView/types'; | import { OnMessageEvent } from '../../ExtendedWebView/types'; | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -85,6 +87,8 @@ const ImageEditor = (props: Props) => { | |||||||
| 	const webviewRef: MutableRefObject<WebViewControl>|null = useRef(null); | 	const webviewRef: MutableRefObject<WebViewControl>|null = useRef(null); | ||||||
| 	const [imageChanged, setImageChanged] = useState(false); | 	const [imageChanged, setImageChanged] = useState(false); | ||||||
|  |  | ||||||
|  | 	const dialogs = useContext(DialogContext); | ||||||
|  |  | ||||||
| 	const onRequestCloseEditor = useCallback((promptIfUnsaved: boolean) => { | 	const onRequestCloseEditor = useCallback((promptIfUnsaved: boolean) => { | ||||||
| 		const discardChangesAndClose = async () => { | 		const discardChangesAndClose = async () => { | ||||||
| 			await clearAutosave(); | 			await clearAutosave(); | ||||||
| @@ -96,7 +100,7 @@ const ImageEditor = (props: Props) => { | |||||||
| 			return true; | 			return true; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		Alert.alert( | 		dialogs.prompt( | ||||||
| 			_('Save changes?'), _('This drawing may have unsaved changes.'), [ | 			_('Save changes?'), _('This drawing may have unsaved changes.'), [ | ||||||
| 				{ | 				{ | ||||||
| 					text: _('Discard changes'), | 					text: _('Discard changes'), | ||||||
| @@ -114,7 +118,7 @@ const ImageEditor = (props: Props) => { | |||||||
| 			], | 			], | ||||||
| 		); | 		); | ||||||
| 		return true; | 		return true; | ||||||
| 	}, [webviewRef, props.onExit, imageChanged]); | 	}, [webviewRef, dialogs, props.onExit, imageChanged]); | ||||||
|  |  | ||||||
| 	useEffect(() => { | 	useEffect(() => { | ||||||
| 		const hardwareBackPressListener = () => { | 		const hardwareBackPressListener = () => { | ||||||
| @@ -268,14 +272,28 @@ const ImageEditor = (props: Props) => { | |||||||
| 	}, [css]); | 	}, [css]); | ||||||
|  |  | ||||||
| 	const onReadyToLoadData = useCallback(async () => { | 	const onReadyToLoadData = useCallback(async () => { | ||||||
|  | 		const getInitialInjectedData = async () => { | ||||||
|  | 			// On mobile, it's faster to load the image within the WebView with an XMLHttpRequest. | ||||||
|  | 			// In this case, the image is loaded elsewhere. | ||||||
|  | 			if (Platform.OS !== 'web') { | ||||||
|  | 				return undefined; | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			// On web, however, this doesn't work, so the image needs to be loaded here. | ||||||
|  | 			if (!props.resourceFilename) { | ||||||
|  | 				return ''; | ||||||
|  | 			} | ||||||
|  | 			return await shim.fsDriver().readFile(props.resourceFilename, 'utf-8'); | ||||||
|  | 		}; | ||||||
| 		// It can take some time for initialSVGData to be transferred to the WebView. | 		// It can take some time for initialSVGData to be transferred to the WebView. | ||||||
| 		// Thus, do so after the main content has been loaded. | 		// Thus, do so after the main content has been loaded. | ||||||
| 		webviewRef.current.injectJS(`(async () => { | 		webviewRef.current.injectJS(`(async () => { | ||||||
| 			if (window.editorControl) { | 			if (window.editorControl) { | ||||||
| 				const initialSVGPath = ${JSON.stringify(props.resourceFilename)}; | 				const initialSVGPath = ${JSON.stringify(props.resourceFilename)}; | ||||||
| 				const initialTemplateData = ${JSON.stringify(Setting.value('imageeditor.imageTemplate'))}; | 				const initialTemplateData = ${JSON.stringify(Setting.value('imageeditor.imageTemplate'))}; | ||||||
|  | 				const initialData = ${JSON.stringify(await getInitialInjectedData())}; | ||||||
|  |  | ||||||
| 				editorControl.loadImageOrTemplate(initialSVGPath, initialTemplateData); | 				editorControl.loadImageOrTemplate(initialSVGPath, initialTemplateData, initialData); | ||||||
| 			} | 			} | ||||||
| 		})();`); | 		})();`); | ||||||
| 	}, [webviewRef, props.resourceFilename]); | 	}, [webviewRef, props.resourceFilename]); | ||||||
|   | |||||||
| @@ -58,7 +58,7 @@ describe('createJsDrawEditor', () => { | |||||||
| 		}); | 		}); | ||||||
|  |  | ||||||
| 		// Load no image and an empty template so that autosave can start | 		// Load no image and an empty template so that autosave can start | ||||||
| 		await editorControl.loadImageOrTemplate('', '{}'); | 		await editorControl.loadImageOrTemplate('', '{}', undefined); | ||||||
|  |  | ||||||
| 		expect(calledAutosaveCount).toBe(0); | 		expect(calledAutosaveCount).toBe(0); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -149,11 +149,13 @@ export const createJsDrawEditor = ( | |||||||
|  |  | ||||||
| 	const editorControl = { | 	const editorControl = { | ||||||
| 		editor, | 		editor, | ||||||
| 		loadImageOrTemplate: async (resourceUrl: string, templateData: string) => { | 		loadImageOrTemplate: async (resourceUrl: string, templateData: string, svgData: string|undefined) => { | ||||||
| 			// loadFromSVG shows its own loading message. Hide the original. | 			// loadFromSVG shows its own loading message. Hide the original. | ||||||
| 			editor.hideLoadingWarning(); | 			editor.hideLoadingWarning(); | ||||||
|  |  | ||||||
| 			const svgData = await fetchInitialSvgData(resourceUrl); | 			// On mobile, fetching the SVG data is much faster than transferring it via IPC. However, fetch | ||||||
|  | 			// doesn't work for this when running in a web browser (virtual file system). | ||||||
|  | 			svgData ??= await fetchInitialSvgData(resourceUrl); | ||||||
|  |  | ||||||
| 			// Load from a template if no initial data | 			// Load from a template if no initial data | ||||||
| 			if (svgData === '') { | 			if (svgData === '') { | ||||||
|   | |||||||
| @@ -92,6 +92,16 @@ const Toolbar: React.FC<ToolbarProps> = (props: ToolbarProps) => { | |||||||
| 		</View> | 		</View> | ||||||
| 	); | 	); | ||||||
|  |  | ||||||
|  | 	const overflow = ( | ||||||
|  | 		<ScrollView style={{ flex: 1 }}> | ||||||
|  | 			<ToolbarOverflowRows | ||||||
|  | 				buttonGroups={props.buttons} | ||||||
|  | 				styleSheet={props.styleSheet} | ||||||
|  | 				onToggleOverflow={onToggleOverflowVisible} | ||||||
|  | 			/> | ||||||
|  | 		</ScrollView> | ||||||
|  | 	); | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<View | 		<View | ||||||
| 			style={{ | 			style={{ | ||||||
| @@ -106,14 +116,7 @@ const Toolbar: React.FC<ToolbarProps> = (props: ToolbarProps) => { | |||||||
| 			}} | 			}} | ||||||
| 			onLayout={onContainerLayout} | 			onLayout={onContainerLayout} | ||||||
| 		> | 		> | ||||||
| 			<ScrollView> | 			{ overflowButtonsVisible ? overflow : null } | ||||||
| 				<ToolbarOverflowRows |  | ||||||
| 					buttonGroups={props.buttons} |  | ||||||
| 					styleSheet={props.styleSheet} |  | ||||||
| 					visible={overflowButtonsVisible} |  | ||||||
| 					onToggleOverflow={onToggleOverflowVisible} |  | ||||||
| 				/> |  | ||||||
| 			</ScrollView> |  | ||||||
| 			{ !overflowButtonsVisible ? mainButtonRow : null } | 			{ !overflowButtonsVisible ? mainButtonRow : null } | ||||||
| 		</View> | 		</View> | ||||||
| 	); | 	); | ||||||
|   | |||||||
| @@ -63,6 +63,7 @@ const ToolbarButton = ({ styleSheet, spec, onActionComplete, style }: ToolbarBut | |||||||
| 			onPress={onPress} | 			onPress={onPress} | ||||||
| 			description={ spec.description } | 			description={ spec.description } | ||||||
| 			disabled={ disabled } | 			disabled={ disabled } | ||||||
|  | 			preventKeyboardDismiss={true} | ||||||
|  |  | ||||||
| 			iconName={spec.icon} | 			iconName={spec.icon} | ||||||
| 			iconStyle={styles.iconStyle} | 			iconStyle={styles.iconStyle} | ||||||
|   | |||||||
| @@ -11,7 +11,6 @@ type OnToggleOverflowCallback = ()=> void; | |||||||
| interface OverflowPopupProps { | interface OverflowPopupProps { | ||||||
| 	buttonGroups: ButtonGroup[]; | 	buttonGroups: ButtonGroup[]; | ||||||
| 	styleSheet: StyleSheetData; | 	styleSheet: StyleSheetData; | ||||||
| 	visible: boolean; |  | ||||||
|  |  | ||||||
| 	// Should be created using useCallback | 	// Should be created using useCallback | ||||||
| 	onToggleOverflow: OnToggleOverflowCallback; | 	onToggleOverflow: OnToggleOverflowCallback; | ||||||
| @@ -117,16 +116,13 @@ const ToolbarOverflowRows: React.FC<OverflowPopupProps> = (props: OverflowPopupP | |||||||
| 		/> | 		/> | ||||||
| 	); | 	); | ||||||
|  |  | ||||||
| 	if (!props.visible) { |  | ||||||
| 		return null; |  | ||||||
| 	} |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<View | 		<View | ||||||
| 			style={{ | 			style={{ | ||||||
| 				height: props.buttonGroups.length * buttonSize, | 				height: props.buttonGroups.length * buttonSize, | ||||||
| 				flexDirection: 'column', | 				flexDirection: 'column', | ||||||
| 				flexGrow: 1, | 				flexGrow: 1, | ||||||
| 				display: !props.visible ? 'none' : 'flex', | 				display: 'flex', | ||||||
| 			}} | 			}} | ||||||
| 			onLayout={onContainerLayout} | 			onLayout={onContainerLayout} | ||||||
| 		> | 		> | ||||||
|   | |||||||
| @@ -4,7 +4,8 @@ import { themeStyle } from '@joplin/lib/theme'; | |||||||
| import themeToCss from '@joplin/lib/services/style/themeToCss'; | import themeToCss from '@joplin/lib/services/style/themeToCss'; | ||||||
| import EditLinkDialog from './EditLinkDialog'; | import EditLinkDialog from './EditLinkDialog'; | ||||||
| import { defaultSearchState, SearchPanel } from './SearchPanel'; | import { defaultSearchState, SearchPanel } from './SearchPanel'; | ||||||
| import ExtendedWebView, { WebViewControl } from '../ExtendedWebView'; | import ExtendedWebView from '../ExtendedWebView'; | ||||||
|  | import { WebViewControl } from '../ExtendedWebView/types'; | ||||||
|  |  | ||||||
| import * as React from 'react'; | import * as React from 'react'; | ||||||
| import { forwardRef, RefObject, useEffect, useImperativeHandle } from 'react'; | import { forwardRef, RefObject, useEffect, useImperativeHandle } from 'react'; | ||||||
| @@ -71,9 +72,8 @@ function useCss(themeId: number): string { | |||||||
| 			body { | 			body { | ||||||
| 				margin: 0; | 				margin: 0; | ||||||
| 				height: 100vh; | 				height: 100vh; | ||||||
| 				width: 100vh; | 				/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */ | ||||||
| 				width: 100vw; | 				width: 100%; | ||||||
| 				min-width: 100vw; |  | ||||||
| 				box-sizing: border-box; | 				box-sizing: border-box; | ||||||
|  |  | ||||||
| 				padding-left: 1px; | 				padding-left: 1px; | ||||||
| @@ -83,6 +83,44 @@ function useCss(themeId: number): string { | |||||||
|  |  | ||||||
| 				font-size: 13pt; | 				font-size: 13pt; | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
|  | 			* { | ||||||
|  | 				scrollbar-width: thin; | ||||||
|  | 				scrollbar-color: rgba(100, 100, 100, 0.7) rgba(0, 0, 0, 0.1); | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			@supports selector(::-webkit-scrollbar) { | ||||||
|  | 				*::-webkit-scrollbar { | ||||||
|  | 					width: 7px; | ||||||
|  | 					height: 7px; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				*::-webkit-scrollbar-corner { | ||||||
|  | 					background: none; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				*::-webkit-scrollbar-track { | ||||||
|  | 					border: none; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				*::-webkit-scrollbar-thumb { | ||||||
|  | 					background: rgba(100, 100, 100, 0.3); | ||||||
|  | 					border-radius: 5px; | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				*::-webkit-scrollbar-track:hover { | ||||||
|  | 					background: rgba(0, 0, 0, 0.1); | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				*::-webkit-scrollbar-thumb:hover { | ||||||
|  | 					background: rgba(100, 100, 100, 0.7); | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				* { | ||||||
|  | 					scrollbar-width: unset; | ||||||
|  | 					scrollbar-color: unset; | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
| 		`; | 		`; | ||||||
| 	}, [themeId]); | 	}, [themeId]); | ||||||
| } | } | ||||||
| @@ -469,7 +507,7 @@ function NoteEditor(props: Props, ref: any) { | |||||||
| 	const onMessage = useCallback((event: OnMessageEvent) => { | 	const onMessage = useCallback((event: OnMessageEvent) => { | ||||||
| 		const data = event.nativeEvent.data; | 		const data = event.nativeEvent.data; | ||||||
|  |  | ||||||
| 		if (data.indexOf('error:') === 0) { | 		if (typeof data === 'string' && data.indexOf('error:') === 0) { | ||||||
| 			logger.error('CodeMirror error', data); | 			logger.error('CodeMirror error', data); | ||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -1,16 +1,17 @@ | |||||||
| const React = require('react'); | const React = require('react'); | ||||||
| import { useCallback, useMemo, useState } from 'react'; | import { useCallback, useContext, useMemo, useState } from 'react'; | ||||||
| const { View, FlatList, StyleSheet } = require('react-native'); | const { View, FlatList, StyleSheet } = require('react-native'); | ||||||
| import createRootStyle from '../../utils/createRootStyle'; | import createRootStyle from '../../utils/createRootStyle'; | ||||||
| import ScreenHeader from '../ScreenHeader'; | import ScreenHeader from '../ScreenHeader'; | ||||||
| const { FAB, List } = require('react-native-paper'); | const { FAB, List } = require('react-native-paper'); | ||||||
| import { Profile } from '@joplin/lib/services/profileConfig/types'; | import { Profile } from '@joplin/lib/services/profileConfig/types'; | ||||||
| import useProfileConfig from './useProfileConfig'; | import useProfileConfig from './useProfileConfig'; | ||||||
| import { Alert } from 'react-native'; |  | ||||||
| import { _ } from '@joplin/lib/locale'; | import { _ } from '@joplin/lib/locale'; | ||||||
| import { deleteProfileById } from '@joplin/lib/services/profileConfig'; | import { deleteProfileById } from '@joplin/lib/services/profileConfig'; | ||||||
| import { saveProfileConfig, switchProfile } from '../../services/profiles'; | import { saveProfileConfig, switchProfile } from '../../services/profiles'; | ||||||
| import { themeStyle } from '../global-style'; | import { themeStyle } from '../global-style'; | ||||||
|  | import shim from '@joplin/lib/shim'; | ||||||
|  | import { DialogContext } from '../DialogManager'; | ||||||
|  |  | ||||||
| interface Props { | interface Props { | ||||||
| 	themeId: number; | 	themeId: number; | ||||||
| @@ -48,32 +49,41 @@ export default (props: Props) => { | |||||||
| 		return profileConfig ? profileConfig.profiles : []; | 		return profileConfig ? profileConfig.profiles : []; | ||||||
| 	}, [profileConfig]); | 	}, [profileConfig]); | ||||||
|  |  | ||||||
|  | 	const dialogs = useContext(DialogContext); | ||||||
|  |  | ||||||
| 	const onProfileItemPress = useCallback(async (profile: Profile) => { | 	const onProfileItemPress = useCallback(async (profile: Profile) => { | ||||||
| 		const doIt = async () => { | 		const doIt = async () => { | ||||||
| 			try { | 			try { | ||||||
| 				await switchProfile(profile.id); | 				await switchProfile(profile.id); | ||||||
| 			} catch (error) { | 			} catch (error) { | ||||||
| 				Alert.alert(_('Could not switch profile: %s', error.message)); | 				dialogs.prompt(_('Error'), _('Could not switch profile: %s', error.message)); | ||||||
| 			} | 			} | ||||||
| 		}; | 		}; | ||||||
|  |  | ||||||
| 		Alert.alert( | 		const switchProfileMessage = _('To switch the profile, the app is going to close and you will need to restart it.'); | ||||||
| 			_('Confirmation'), | 		if (shim.mobilePlatform() === 'web') { | ||||||
| 			_('To switch the profile, the app is going to close and you will need to restart it.'), | 			if (confirm(switchProfileMessage)) { | ||||||
| 			[ | 				void doIt(); | ||||||
| 				{ | 			} | ||||||
| 					text: _('Continue'), | 		} else { | ||||||
| 					onPress: () => doIt(), | 			dialogs.prompt( | ||||||
| 					style: 'default', | 				_('Confirmation'), | ||||||
| 				}, | 				switchProfileMessage, | ||||||
| 				{ | 				[ | ||||||
| 					text: _('Cancel'), | 					{ | ||||||
| 					onPress: () => {}, | 						text: _('Continue'), | ||||||
| 					style: 'cancel', | 						onPress: () => doIt(), | ||||||
| 				}, | 						style: 'default', | ||||||
| 			], | 					}, | ||||||
| 		); | 					{ | ||||||
| 	}, []); | 						text: _('Cancel'), | ||||||
|  | 						onPress: () => {}, | ||||||
|  | 						style: 'cancel', | ||||||
|  | 					}, | ||||||
|  | 				], | ||||||
|  | 			); | ||||||
|  | 		} | ||||||
|  | 	}, [dialogs]); | ||||||
|  |  | ||||||
| 	const onEditProfile = useCallback(async (profileId: string) => { | 	const onEditProfile = useCallback(async (profileId: string) => { | ||||||
| 		props.dispatch({ | 		props.dispatch({ | ||||||
| @@ -90,11 +100,11 @@ export default (props: Props) => { | |||||||
| 				await saveProfileConfig(newConfig); | 				await saveProfileConfig(newConfig); | ||||||
| 				setProfileConfigTime(Date.now()); | 				setProfileConfigTime(Date.now()); | ||||||
| 			} catch (error) { | 			} catch (error) { | ||||||
| 				Alert.alert(error.message); | 				dialogs.prompt(_('Error'), error.message); | ||||||
| 			} | 			} | ||||||
| 		}; | 		}; | ||||||
|  |  | ||||||
| 		Alert.alert( | 		dialogs.prompt( | ||||||
| 			_('Delete this profile?'), | 			_('Delete this profile?'), | ||||||
| 			_('All data, including notes, notebooks and tags will be permanently deleted.'), | 			_('All data, including notes, notebooks and tags will be permanently deleted.'), | ||||||
| 			[ | 			[ | ||||||
| @@ -110,11 +120,37 @@ export default (props: Props) => { | |||||||
| 				}, | 				}, | ||||||
| 			], | 			], | ||||||
| 		); | 		); | ||||||
| 	}, [profileConfig]); | 	}, [dialogs, profileConfig]); | ||||||
|  |  | ||||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||||
| 	const renderProfileItem = (event: any) => { | 	const renderProfileItem = (event: any) => { | ||||||
| 		const profile = event.item as Profile; | 		const profile = event.item as Profile; | ||||||
|  | 		const onConfigure = (event: Event) => { | ||||||
|  | 			event.preventDefault(); | ||||||
|  |  | ||||||
|  | 			dialogs.prompt( | ||||||
|  | 				_('Configuration'), | ||||||
|  | 				'', | ||||||
|  | 				[ | ||||||
|  | 					{ | ||||||
|  | 						text: _('Edit'), | ||||||
|  | 						onPress: () => onEditProfile(profile.id), | ||||||
|  | 						style: 'default', | ||||||
|  | 					}, | ||||||
|  | 					{ | ||||||
|  | 						text: _('Delete'), | ||||||
|  | 						onPress: () => onDeleteProfile(profile), | ||||||
|  | 						style: 'default', | ||||||
|  | 					}, | ||||||
|  | 					{ | ||||||
|  | 						text: _('Close'), | ||||||
|  | 						onPress: () => {}, | ||||||
|  | 						style: 'cancel', | ||||||
|  | 					}, | ||||||
|  | 				], | ||||||
|  | 			); | ||||||
|  | 		}; | ||||||
|  |  | ||||||
| 		const titleStyle = { fontWeight: profile.id === profileConfig.currentProfileId ? 'bold' : 'normal' }; | 		const titleStyle = { fontWeight: profile.id === profileConfig.currentProfileId ? 'bold' : 'normal' }; | ||||||
| 		return ( | 		return ( | ||||||
| 			<List.Item | 			<List.Item | ||||||
| @@ -125,29 +161,8 @@ export default (props: Props) => { | |||||||
| 				key={profile.id} | 				key={profile.id} | ||||||
| 				profileId={profile.id} | 				profileId={profile.id} | ||||||
| 				onPress={() => { void onProfileItemPress(profile); }} | 				onPress={() => { void onProfileItemPress(profile); }} | ||||||
| 				onLongPress={() => { | 				onLongPress={onConfigure} | ||||||
| 					Alert.alert( | 				onContextMenu={onConfigure} | ||||||
| 						_('Configuration'), |  | ||||||
| 						'', |  | ||||||
| 						[ |  | ||||||
| 							{ |  | ||||||
| 								text: _('Edit'), |  | ||||||
| 								onPress: () => onEditProfile(profile.id), |  | ||||||
| 								style: 'default', |  | ||||||
| 							}, |  | ||||||
| 							{ |  | ||||||
| 								text: _('Delete'), |  | ||||||
| 								onPress: () => onDeleteProfile(profile), |  | ||||||
| 								style: 'default', |  | ||||||
| 							}, |  | ||||||
| 							{ |  | ||||||
| 								text: _('Close'), |  | ||||||
| 								onPress: () => {}, |  | ||||||
| 								style: 'cancel', |  | ||||||
| 							}, |  | ||||||
| 						], |  | ||||||
| 					); |  | ||||||
| 				}} |  | ||||||
| 			/> | 			/> | ||||||
| 		); | 		); | ||||||
| 	}; | 	}; | ||||||
|   | |||||||
| @@ -0,0 +1,60 @@ | |||||||
|  | import * as React from 'react'; | ||||||
|  | import { Linking, 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'; | ||||||
|  |  | ||||||
|  | interface Props { | ||||||
|  | 	wrapperStyle: ViewStyle; | ||||||
|  | 	iconStyle: TextStyle; | ||||||
|  | 	themeId: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const onLeaveFeedback = () => { | ||||||
|  | 	void Linking.openURL('https://discourse.joplinapp.org/t/web-client-running-joplin-mobile-in-a-web-browser-with-react-native-web/38749'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const feedbackContainerStyles: ViewStyle = { flexGrow: 1, justifyContent: 'flex-end' }; | ||||||
|  |  | ||||||
|  | const WebBetaButton: React.FC<Props> = props => { | ||||||
|  | 	const [dialogVisible, setDialogVisible] = useState(false); | ||||||
|  |  | ||||||
|  | 	const onShowDialog = useCallback(() => { | ||||||
|  | 		setDialogVisible(true); | ||||||
|  | 	}, []); | ||||||
|  |  | ||||||
|  | 	const onHideDialog = useCallback(() => { | ||||||
|  | 		setDialogVisible(false); | ||||||
|  | 	}, []); | ||||||
|  |  | ||||||
|  | 	return ( | ||||||
|  | 		<> | ||||||
|  | 			<IconButton | ||||||
|  | 				onPress={onShowDialog} | ||||||
|  | 				description={_('Beta')} | ||||||
|  | 				themeId={props.themeId} | ||||||
|  | 				contentWrapperStyle={props.wrapperStyle} | ||||||
|  |  | ||||||
|  | 				iconName="material beta" | ||||||
|  | 				iconStyle={props.iconStyle} | ||||||
|  | 			/> | ||||||
|  | 			<DismissibleDialog | ||||||
|  | 				size={DialogSize.Small} | ||||||
|  | 				themeId={props.themeId} | ||||||
|  | 				visible={dialogVisible} | ||||||
|  | 				onDismiss={onHideDialog} | ||||||
|  | 			> | ||||||
|  | 				<Text variant='headlineMedium'>{_('Beta')}</Text> | ||||||
|  | 				<Text>{'At present, the web client is in beta. In the future, it may change significantly, or be removed.'}</Text> | ||||||
|  | 				<View style={feedbackContainerStyles}> | ||||||
|  | 					<LinkButton onPress={onLeaveFeedback}>{_('Give feedback')}</LinkButton> | ||||||
|  | 				</View> | ||||||
|  | 			</DismissibleDialog> | ||||||
|  | 		</> | ||||||
|  | 	); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default WebBetaButton; | ||||||
| @@ -1,7 +1,7 @@ | |||||||
| import * as React from 'react'; | import * as React from 'react'; | ||||||
| import { PureComponent, ReactElement } from 'react'; | import { PureComponent, ReactElement } from 'react'; | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import { View, Text, StyleSheet, TouchableOpacity, Image, ScrollView, Dimensions, ViewStyle } from 'react-native'; | import { View, Text, StyleSheet, TouchableOpacity, Image, ScrollView, Dimensions, ViewStyle, Platform } from 'react-native'; | ||||||
| const Icon = require('react-native-vector-icons/Ionicons').default; | const Icon = require('react-native-vector-icons/Ionicons').default; | ||||||
| const { BackButtonService } = require('../../services/back-button.js'); | const { BackButtonService } = require('../../services/back-button.js'); | ||||||
| import NavService from '@joplin/lib/services/NavService'; | import NavService from '@joplin/lib/services/NavService'; | ||||||
| @@ -24,6 +24,7 @@ import { PluginStates } from '@joplin/lib/services/plugins/reducer'; | |||||||
| import { ContainerType } from '@joplin/lib/services/plugins/WebviewController'; | import { ContainerType } from '@joplin/lib/services/plugins/WebviewController'; | ||||||
| import { Dispatch } from 'redux'; | import { Dispatch } from 'redux'; | ||||||
| import WarningBanner from './WarningBanner'; | import WarningBanner from './WarningBanner'; | ||||||
|  | import WebBetaButton from './WebBetaButton'; | ||||||
|  |  | ||||||
| // Rather than applying a padding to the whole bar, it is applied to each | // Rather than applying a padding to the whole bar, it is applied to each | ||||||
| // individual component (button, picker, etc.) so that the touchable areas | // individual component (button, picker, etc.) so that the touchable areas | ||||||
| @@ -112,7 +113,6 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade | |||||||
| 			container: { | 			container: { | ||||||
| 				flexDirection: 'column', | 				flexDirection: 'column', | ||||||
| 				backgroundColor: theme.backgroundColor2, | 				backgroundColor: theme.backgroundColor2, | ||||||
| 				alignItems: 'center', |  | ||||||
| 				shadowColor: '#000000', | 				shadowColor: '#000000', | ||||||
| 				elevation: 5, | 				elevation: 5, | ||||||
| 			}, | 			}, | ||||||
| @@ -451,6 +451,18 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade | |||||||
| 			); | 			); | ||||||
| 		}; | 		}; | ||||||
|  |  | ||||||
|  | 		const betaIconButton = () => { | ||||||
|  | 			if (Platform.OS !== 'web') return null; | ||||||
|  |  | ||||||
|  | 			return ( | ||||||
|  | 				<WebBetaButton | ||||||
|  | 					themeId={themeId} | ||||||
|  | 					wrapperStyle={this.styles().iconButton} | ||||||
|  | 					iconStyle={this.styles().topIcon} | ||||||
|  | 				/> | ||||||
|  | 			); | ||||||
|  | 		}; | ||||||
|  |  | ||||||
| 		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | 		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||||
| 		function deleteButton(styles: any, onPress: OnPressCallback, disabled: boolean) { | 		function deleteButton(styles: any, onPress: OnPressCallback, disabled: boolean) { | ||||||
| 			return ( | 			return ( | ||||||
| @@ -633,6 +645,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade | |||||||
| 		const sideMenuComp = !showSideMenuButton ? null : sideMenuButton(this.styles(), () => this.sideMenuButton_press()); | 		const sideMenuComp = !showSideMenuButton ? null : sideMenuButton(this.styles(), () => this.sideMenuButton_press()); | ||||||
| 		const backButtonComp = !showBackButton ? null : backButton(this.styles(), () => this.backButton_press(), backButtonDisabled); | 		const backButtonComp = !showBackButton ? null : backButton(this.styles(), () => this.backButton_press(), backButtonDisabled); | ||||||
| 		const pluginPanelsComp = pluginPanelToggleButton(this.styles(), () => this.pluginPanelToggleButton_press()); | 		const pluginPanelsComp = pluginPanelToggleButton(this.styles(), () => this.pluginPanelToggleButton_press()); | ||||||
|  | 		const betaIconComp = betaIconButton(); | ||||||
| 		const selectAllButtonComp = !showSelectAllButton ? null : selectAllButton(this.styles(), () => this.selectAllButton_press()); | 		const selectAllButtonComp = !showSelectAllButton ? null : selectAllButton(this.styles(), () => this.selectAllButton_press()); | ||||||
| 		const searchButtonComp = !showSearchButton ? null : searchButton(this.styles(), () => this.searchButton_press()); | 		const searchButtonComp = !showSearchButton ? null : searchButton(this.styles(), () => this.searchButton_press()); | ||||||
| 		const deleteButtonComp = !selectedFolderInTrash && this.props.noteSelectionEnabled ? deleteButton(this.styles(), () => this.deleteButton_press(), headerItemDisabled) : null; | 		const deleteButtonComp = !selectedFolderInTrash && this.props.noteSelectionEnabled ? deleteButton(this.styles(), () => this.deleteButton_press(), headerItemDisabled) : null; | ||||||
| @@ -642,7 +655,10 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade | |||||||
|  |  | ||||||
| 		// To allow the notebook dropdown (and perhaps other components) to have sufficient | 		// To allow the notebook dropdown (and perhaps other components) to have sufficient | ||||||
| 		// space while in use, we allow certain buttons to be hidden. | 		// space while in use, we allow certain buttons to be hidden. | ||||||
| 		const hideableRightComponents = pluginPanelsComp; | 		const hideableRightComponents = <> | ||||||
|  | 			{pluginPanelsComp} | ||||||
|  | 			{betaIconComp} | ||||||
|  | 		</>; | ||||||
|  |  | ||||||
| 		const titleComp = createTitleComponent(headerItemDisabled, hideableRightComponents); | 		const titleComp = createTitleComponent(headerItemDisabled, hideableRightComponents); | ||||||
| 		const windowHeight = Dimensions.get('window').height - 50; | 		const windowHeight = Dimensions.get('window').height - 50; | ||||||
|   | |||||||
| @@ -1,8 +1,11 @@ | |||||||
| import * as React from 'react'; | import * as React from 'react'; | ||||||
| import { themeStyle } from './global-style'; | import { themeStyle } from './global-style'; | ||||||
| import { _ } from '@joplin/lib/locale'; | import { _ } from '@joplin/lib/locale'; | ||||||
| const { Modal, View, Button, Text, StyleSheet } = require('react-native'); | const { View, Button, Text, StyleSheet } = require('react-native'); | ||||||
| import time from '@joplin/lib/time'; | import time from '@joplin/lib/time'; | ||||||
|  | import { Platform } from 'react-native'; | ||||||
|  | import Modal from './Modal'; | ||||||
|  | import { formatMsToLocal } from '@joplin/utils/time'; | ||||||
| const DateTimePickerModal = require('react-native-modal-datetime-picker').default; | const DateTimePickerModal = require('react-native-modal-datetime-picker').default; | ||||||
|  |  | ||||||
| const styles = StyleSheet.create({ | const styles = StyleSheet.create({ | ||||||
| @@ -10,7 +13,6 @@ const styles = StyleSheet.create({ | |||||||
| 		flex: 1, | 		flex: 1, | ||||||
| 		justifyContent: 'center', | 		justifyContent: 'center', | ||||||
| 		alignItems: 'center', | 		alignItems: 'center', | ||||||
| 		marginTop: 22, |  | ||||||
| 	}, | 	}, | ||||||
| 	modalView: { | 	modalView: { | ||||||
| 		display: 'flex', | 		display: 'flex', | ||||||
| @@ -100,9 +102,26 @@ export default class SelectDateTimeDialog extends React.PureComponent<any, any> | |||||||
| 		this.setState({ showPicker: true }); | 		this.setState({ showPicker: true }); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	// web | ||||||
|  | 	private onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|  | 		this.setState({ date: new Date(event.target.value) }); | ||||||
|  | 	}; | ||||||
|  |  | ||||||
| 	public renderContent() { | 	public renderContent() { | ||||||
| 		const theme = themeStyle(this.props.themeId); | 		const theme = themeStyle(this.props.themeId); | ||||||
|  |  | ||||||
|  | 		// DateTimePickerModal doesn't support web. | ||||||
|  | 		if (Platform.OS === 'web') { | ||||||
|  | 			// See https://developer.mozilla.org/en-US/docs/Web/HTML/Date_and_time_formats#local_date_and_time_strings | ||||||
|  | 			// for the expected date input format: | ||||||
|  | 			const dateString = this.state.date ? formatMsToLocal(this.state.date.getTime(), 'YYYY-MM-DD[T]HH:mm:ss') : ''; | ||||||
|  | 			return <input | ||||||
|  | 				type="datetime-local" | ||||||
|  | 				value={dateString} | ||||||
|  | 				onChange={this.onInputChange} | ||||||
|  | 			></input>; | ||||||
|  | 		} | ||||||
|  |  | ||||||
| 		return ( | 		return ( | ||||||
| 			<View style={{ flex: 0, margin: 20, alignItems: 'center' }}> | 			<View style={{ flex: 0, margin: 20, alignItems: 'center' }}> | ||||||
| 				<View style={{ flexDirection: 'row', alignItems: 'center' }}> | 				<View style={{ flexDirection: 'row', alignItems: 'center' }}> | ||||||
| @@ -129,36 +148,32 @@ export default class SelectDateTimeDialog extends React.PureComponent<any, any> | |||||||
| 		const theme = themeStyle(this.props.themeId); | 		const theme = themeStyle(this.props.themeId); | ||||||
|  |  | ||||||
| 		return ( | 		return ( | ||||||
| 			<View style={styles.centeredView}> | 			<Modal | ||||||
| 				<Modal | 				transparent={true} | ||||||
|  | 				visible={modalVisible} | ||||||
| 					transparent={true} | 				containerStyle={styles.centeredView} | ||||||
| 					visible={modalVisible} | 				onRequestClose={() => { | ||||||
| 					onRequestClose={() => { | 					this.onReject(); | ||||||
| 						this.onReject(); | 				}} | ||||||
| 					}} | 			> | ||||||
| 				> | 				<View style={{ ...styles.modalView, backgroundColor: theme.backgroundColor }}> | ||||||
| 					<View style={styles.centeredView}> | 					<View style={{ padding: 15, flexBasis: 'auto', paddingBottom: 0, flexGrow: 0, width: '100%', borderBottomWidth: 1, borderBottomColor: theme.dividerColor, borderBottomStyle: 'solid' }}> | ||||||
| 						<View style={{ ...styles.modalView, backgroundColor: theme.backgroundColor }}> | 						<Text style={{ ...styles.modalText, color: theme.color, fontSize: 14, fontWeight: 'bold' }}>{_('Set alarm')}</Text> | ||||||
| 							<View style={{ padding: 15, paddingBottom: 0, flex: 0, width: '100%', borderBottomWidth: 1, borderBottomColor: theme.dividerColor, borderBottomStyle: 'solid' }}> | 					</View> | ||||||
| 								<Text style={{ ...styles.modalText, color: theme.color, fontSize: 14, fontWeight: 'bold' }}>{_('Set alarm')}</Text> | 					{this.renderContent()} | ||||||
| 							</View> | 					<View style={{ padding: 20, flexBasis: 'auto', borderTopWidth: 1, borderTopStyle: 'solid', borderTopColor: theme.dividerColor }}> | ||||||
| 							{this.renderContent()} | 						<View style={{ marginBottom: 10 }}> | ||||||
| 							<View style={{ padding: 20, borderTopWidth: 1, borderTopStyle: 'solid', borderTopColor: theme.dividerColor }}> | 							<Button title={_('Save alarm')} onPress={() => this.onAccept()} key="saveButton" /> | ||||||
| 								<View style={{ marginBottom: 10 }}> | 						</View> | ||||||
| 									<Button title={_('Save alarm')} onPress={() => this.onAccept()} key="saveButton" /> | 						<View style={{ marginBottom: 10 }}> | ||||||
| 								</View> | 							<Button title={_('Clear alarm')} onPress={() => this.onClear()} key="clearButton" /> | ||||||
| 								<View style={{ marginBottom: 10 }}> | 						</View> | ||||||
| 									<Button title={_('Clear alarm')} onPress={() => this.onClear()} key="clearButton" /> | 						<View style={{ marginBottom: 10 }}> | ||||||
| 								</View> | 							<Button title={_('Cancel')} onPress={() => this.onReject()} key="cancelButton" /> | ||||||
| 								<View style={{ marginBottom: 10 }}> |  | ||||||
| 									<Button title={_('Cancel')} onPress={() => this.onReject()} key="cancelButton" /> |  | ||||||
| 								</View> |  | ||||||
| 							</View> |  | ||||||
| 						</View> | 						</View> | ||||||
| 					</View> | 					</View> | ||||||
| 				</Modal> | 				</View> | ||||||
| 			</View> | 			</Modal> | ||||||
| 		); | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -0,0 +1,56 @@ | |||||||
|  | import * as React from 'react'; | ||||||
|  | import { View } from 'react-native'; | ||||||
|  | import Modal from '../Modal'; | ||||||
|  | import { useCallback, useState } from 'react'; | ||||||
|  | import { _ } from '@joplin/lib/locale'; | ||||||
|  | import { PrimaryButton, SecondaryButton } from '../buttons'; | ||||||
|  |  | ||||||
|  | interface MenuItem { | ||||||
|  | 	label: string; | ||||||
|  | 	onPress?: ()=> void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface Props { | ||||||
|  | 	label: string; | ||||||
|  | 	onPress: ()=> void; | ||||||
|  | 	actions: MenuItem[]|null; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // react-native-paper's floating action button menu is inaccessible on web | ||||||
|  | // (can't be activated by a screen reader, and, in some cases, by the tab key). | ||||||
|  | // This component provides an alternative. | ||||||
|  |  | ||||||
|  | const AccessibleModalMenu: React.FC<Props> = props => { | ||||||
|  | 	const [open, setOpen] = useState(false); | ||||||
|  |  | ||||||
|  | 	const onClick = useCallback(() => { | ||||||
|  | 		if (props.onPress) { | ||||||
|  | 			props.onPress(); | ||||||
|  | 		} else { | ||||||
|  | 			setOpen(!open); | ||||||
|  | 		} | ||||||
|  | 	}, [open, props.onPress]); | ||||||
|  |  | ||||||
|  | 	const options: React.ReactElement[] = []; | ||||||
|  | 	for (const action of (props.actions ?? [])) { | ||||||
|  | 		options.push( | ||||||
|  | 			<PrimaryButton key={action.label} onPress={action.onPress}> | ||||||
|  | 				{action.label} | ||||||
|  | 			</PrimaryButton>, | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	const modal = ( | ||||||
|  | 		<Modal visible={open}> | ||||||
|  | 			{options} | ||||||
|  | 			<SecondaryButton onPress={onClick}>{_('Close menu')}</SecondaryButton> | ||||||
|  | 		</Modal> | ||||||
|  | 	); | ||||||
|  |  | ||||||
|  | 	return <View style={{ height: 0, overflow: 'visible' }}> | ||||||
|  | 		{modal} | ||||||
|  | 		<SecondaryButton onPress={onClick}>{props.label}</SecondaryButton> | ||||||
|  | 	</View>; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default AccessibleModalMenu; | ||||||
| @@ -0,0 +1,74 @@ | |||||||
|  | import { focus } from '@joplin/lib/utils/focusHandler'; | ||||||
|  | import * as React from 'react'; | ||||||
|  | import { useEffect, useState } from 'react'; | ||||||
|  | import { AccessibilityInfo, findNodeHandle, Platform, UIManager, View, ViewProps } from 'react-native'; | ||||||
|  |  | ||||||
|  | interface Props extends ViewProps { | ||||||
|  | 	// Prevents a view from being interacted with by accessibility tools, the mouse, or the keyboard focus. | ||||||
|  | 	// See https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert. | ||||||
|  | 	inert?: boolean; | ||||||
|  |  | ||||||
|  | 	// When refocusCounter changes, sets the accessibility focus to this view. | ||||||
|  | 	// May require accessible={true}. | ||||||
|  | 	refocusCounter?: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const AccessibleView: React.FC<Props> = ({ inert, refocusCounter, children, ...viewProps }) => { | ||||||
|  | 	const [containerRef, setContainerRef] = useState<View|HTMLElement|null>(null); | ||||||
|  |  | ||||||
|  | 	// On web, there's no clear way to disable keyboard focus for an element **and its descendants** | ||||||
|  | 	// without accessing the underlying HTML. | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		if (!containerRef || Platform.OS !== 'web') return; | ||||||
|  |  | ||||||
|  | 		const element = containerRef as HTMLElement; | ||||||
|  | 		if (inert) { | ||||||
|  | 			element.setAttribute('inert', 'true'); | ||||||
|  | 		} else { | ||||||
|  | 			element.removeAttribute('inert'); | ||||||
|  | 		} | ||||||
|  | 	}, [containerRef, inert]); | ||||||
|  |  | ||||||
|  | 	useEffect(() => { | ||||||
|  | 		if ((refocusCounter ?? null) === null) return; | ||||||
|  |  | ||||||
|  | 		const autoFocus = () => { | ||||||
|  | 			// react-native-web defines UIManager.focus for setting the keyboard focus. However, | ||||||
|  | 			// this property is not available in standard react-native. As such, access it using type | ||||||
|  | 			// narrowing: | ||||||
|  | 			// eslint-disable-next-line no-restricted-properties | ||||||
|  | 			if ('focus' in UIManager && typeof UIManager.focus === 'function') { | ||||||
|  | 				// Disable the "use focusHandler for all focus calls" rule -- UIManager.focus requires | ||||||
|  | 				// an argument, which is not supported by focusHandler. | ||||||
|  | 				// eslint-disable-next-line no-restricted-properties | ||||||
|  | 				UIManager.focus(containerRef); | ||||||
|  | 			} else { | ||||||
|  | 				const handle = findNodeHandle(containerRef as View); | ||||||
|  | 				AccessibilityInfo.setAccessibilityFocus(handle); | ||||||
|  | 			} | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		focus('AccessibleView', { | ||||||
|  | 			focus: autoFocus, | ||||||
|  | 		}); | ||||||
|  | 	}, [containerRef, refocusCounter]); | ||||||
|  |  | ||||||
|  | 	const canFocus = (refocusCounter ?? null) !== null; | ||||||
|  |  | ||||||
|  | 	return <View | ||||||
|  | 		importantForAccessibility={inert ? 'no-hide-descendants' : 'auto'} | ||||||
|  | 		accessibilityElementsHidden={inert} | ||||||
|  | 		aria-hidden={inert} | ||||||
|  | 		pointerEvents={inert ? 'box-none' : 'auto'} | ||||||
|  |  | ||||||
|  | 		// On some platforms, views must have accessible=true to be focused. | ||||||
|  | 		accessible={canFocus ? true : undefined} | ||||||
|  |  | ||||||
|  | 		ref={setContainerRef} | ||||||
|  | 		{...viewProps} | ||||||
|  | 	> | ||||||
|  | 		{children} | ||||||
|  | 	</View>; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default AccessibleView; | ||||||
| @@ -3,8 +3,9 @@ import { useState, useCallback, useMemo } from 'react'; | |||||||
| import { FAB, Portal } from 'react-native-paper'; | import { FAB, Portal } from 'react-native-paper'; | ||||||
| import { _ } from '@joplin/lib/locale'; | import { _ } from '@joplin/lib/locale'; | ||||||
| import { Dispatch } from 'redux'; | import { Dispatch } from 'redux'; | ||||||
| import { useWindowDimensions } from 'react-native'; | import { Platform, useWindowDimensions, View } from 'react-native'; | ||||||
| import shim from '@joplin/lib/shim'; | import shim from '@joplin/lib/shim'; | ||||||
|  | import AccessibleWebMenu from '../accessibility/AccessibleModalMenu'; | ||||||
| const Icon = require('react-native-vector-icons/Ionicons').default; | const Icon = require('react-native-vector-icons/Ionicons').default; | ||||||
| 
 | 
 | ||||||
| // eslint-disable-next-line no-undef -- Don't know why it says React is undefined when it's defined above
 | // eslint-disable-next-line no-undef -- Don't know why it says React is undefined when it's defined above
 | ||||||
| @@ -40,7 +41,7 @@ const useIcon = (iconName: string) => { | |||||||
| 	}, [iconName]); | 	}, [iconName]); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const ActionButton = (props: ActionButtonProps) => { | const FloatingActionButton = (props: ActionButtonProps) => { | ||||||
| 	const [open, setOpen] = useState(false); | 	const [open, setOpen] = useState(false); | ||||||
| 	const onMenuToggled: FABGroupProps['onStateChange'] = useCallback(state => { | 	const onMenuToggled: FABGroupProps['onStateChange'] = useCallback(state => { | ||||||
| 		props.dispatch({ | 		props.dispatch({ | ||||||
| @@ -75,23 +76,47 @@ const ActionButton = (props: ActionButtonProps) => { | |||||||
| 	const marginTop = adjustMargins ? Math.max(0, windowSize.height - 140) : undefined; | 	const marginTop = adjustMargins ? Math.max(0, windowSize.height - 140) : undefined; | ||||||
| 	const marginStart = adjustMargins ? Math.max(0, windowSize.width - 200) : undefined; | 	const marginStart = adjustMargins ? Math.max(0, windowSize.width - 200) : undefined; | ||||||
| 
 | 
 | ||||||
|  | 	const label = props.mainButton?.label ?? _('Add new'); | ||||||
|  | 
 | ||||||
|  | 	// On Web, FAB.Group can't be used at all with accessibility tools. Work around this
 | ||||||
|  | 	// by hiding the FAB for accessibility, and providing a screen-reader-only custom menu.
 | ||||||
|  | 	const isWeb = Platform.OS === 'web'; | ||||||
|  | 	const accessibleMenu = isWeb ? ( | ||||||
|  | 		<AccessibleWebMenu | ||||||
|  | 			label={label} | ||||||
|  | 			onPress={props.mainButton?.onPress} | ||||||
|  | 			actions={props.buttons} | ||||||
|  | 		/> | ||||||
|  | 	) : null; | ||||||
|  | 
 | ||||||
|  | 	const menuContent = <FAB.Group | ||||||
|  | 		open={open} | ||||||
|  | 		accessibilityLabel={label} | ||||||
|  | 		style={{ marginStart, marginTop }} | ||||||
|  | 		icon={ open ? openIcon : closedIcon } | ||||||
|  | 		fabStyle={{ | ||||||
|  | 			backgroundColor: props.mainButton?.color ?? 'rgba(231,76,60,1)', | ||||||
|  | 		}} | ||||||
|  | 		onStateChange={onMenuToggled} | ||||||
|  | 		actions={actions} | ||||||
|  | 		onPress={props.mainButton?.onPress ?? defaultOnPress} | ||||||
|  | 		visible={true} | ||||||
|  | 	/>; | ||||||
|  | 	const mainMenu = isWeb ? ( | ||||||
|  | 		<View | ||||||
|  | 			aria-hidden={true} | ||||||
|  | 			pointerEvents='box-none' | ||||||
|  | 			tabIndex={-1} | ||||||
|  | 			style={{ flex: 1 }} | ||||||
|  | 		>{menuContent}</View> | ||||||
|  | 	) : menuContent; | ||||||
|  | 
 | ||||||
| 	return ( | 	return ( | ||||||
| 		<Portal> | 		<Portal> | ||||||
| 			<FAB.Group | 			{mainMenu} | ||||||
| 				open={open} | 			{accessibleMenu} | ||||||
| 				accessibilityLabel={props.mainButton?.label ?? _('Add new')} |  | ||||||
| 				style={{ marginStart, marginTop }} |  | ||||||
| 				icon={ open ? openIcon : closedIcon } |  | ||||||
| 				fabStyle={{ |  | ||||||
| 					backgroundColor: props.mainButton?.color ?? 'rgba(231,76,60,1)', |  | ||||||
| 				}} |  | ||||||
| 				onStateChange={onMenuToggled} |  | ||||||
| 				actions={actions} |  | ||||||
| 				onPress={props.mainButton?.onPress ?? defaultOnPress} |  | ||||||
| 				visible={true} |  | ||||||
| 			/> |  | ||||||
| 		</Portal> | 		</Portal> | ||||||
| 	); | 	); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default ActionButton; | export default FloatingActionButton; | ||||||
| @@ -1,7 +1,7 @@ | |||||||
| import BasePluginRunner from '@joplin/lib/services/plugins/BasePluginRunner'; | import BasePluginRunner from '@joplin/lib/services/plugins/BasePluginRunner'; | ||||||
| import PluginApiGlobal from '@joplin/lib/services/plugins/api/Global'; | import PluginApiGlobal from '@joplin/lib/services/plugins/api/Global'; | ||||||
| import Plugin from '@joplin/lib/services/plugins/Plugin'; | import Plugin from '@joplin/lib/services/plugins/Plugin'; | ||||||
| import { WebViewControl } from '../ExtendedWebView'; | import { WebViewControl } from '../ExtendedWebView/types'; | ||||||
| import { RefObject } from 'react'; | import { RefObject } from 'react'; | ||||||
| import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger'; | import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger'; | ||||||
| import { PluginMainProcessApi, PluginWebViewApi } from './types'; | import { PluginMainProcessApi, PluginWebViewApi } from './types'; | ||||||
|   | |||||||
| @@ -1,12 +1,12 @@ | |||||||
| import * as React from 'react'; | import * as React from 'react'; | ||||||
| import ExtendedWebView, { WebViewControl } from '../ExtendedWebView'; | import ExtendedWebView from '../ExtendedWebView'; | ||||||
|  | import { WebViewControl } from '../ExtendedWebView/types'; | ||||||
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||||||
| import shim from '@joplin/lib/shim'; | import shim from '@joplin/lib/shim'; | ||||||
| import PluginRunner from './PluginRunner'; | import PluginRunner from './PluginRunner'; | ||||||
| import loadPlugins from '@joplin/lib/services/plugins/loadPlugins'; | import loadPlugins from '@joplin/lib/services/plugins/loadPlugins'; | ||||||
| import { connect, useStore } from 'react-redux'; | import { connect, useStore } from 'react-redux'; | ||||||
| import Logger from '@joplin/utils/Logger'; | import Logger from '@joplin/utils/Logger'; | ||||||
| import { View } from 'react-native'; |  | ||||||
| import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService'; | import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||||
| import { PluginHtmlContents, PluginStates } from '@joplin/lib/services/plugins/reducer'; | import { PluginHtmlContents, PluginStates } from '@joplin/lib/services/plugins/reducer'; | ||||||
| import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; | import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; | ||||||
| @@ -14,6 +14,7 @@ import PluginDialogManager from './dialogs/PluginDialogManager'; | |||||||
| import { AppState } from '../../utils/types'; | import { AppState } from '../../utils/types'; | ||||||
| import usePrevious from '@joplin/lib/hooks/usePrevious'; | import usePrevious from '@joplin/lib/hooks/usePrevious'; | ||||||
| import PlatformImplementation from '../../services/plugins/PlatformImplementation'; | import PlatformImplementation from '../../services/plugins/PlatformImplementation'; | ||||||
|  | import AccessibleView from '../accessibility/AccessibleView'; | ||||||
|  |  | ||||||
| const logger = Logger.create('PluginRunnerWebView'); | const logger = Logger.create('PluginRunnerWebView'); | ||||||
|  |  | ||||||
| @@ -172,9 +173,9 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => { | |||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<View style={{ display: 'none' }}> | 		<AccessibleView style={{ display: 'none' }} inert={true}> | ||||||
| 			{renderWebView()} | 			{renderWebView()} | ||||||
| 		</View> | 		</AccessibleView> | ||||||
| 	); | 	); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -47,6 +47,22 @@ const initializeDialogWebView = (messageChannelId: string) => { | |||||||
| 		includeJsFiles: async (paths: string[]) => { | 		includeJsFiles: async (paths: string[]) => { | ||||||
| 			return includeScriptsOrStyles('js', paths); | 			return includeScriptsOrStyles('js', paths); | ||||||
| 		}, | 		}, | ||||||
|  | 		runScript: async (key: string, scriptData: string) => { | ||||||
|  | 			if (loadedPaths.has(key)) { | ||||||
|  | 				return; | ||||||
|  | 			} | ||||||
|  | 			loadedPaths.add(key); | ||||||
|  |  | ||||||
|  | 			if (key.endsWith('.css')) { | ||||||
|  | 				const stylesheetLink = document.createElement('style'); | ||||||
|  | 				stylesheetLink.appendChild(document.createTextNode(scriptData)); | ||||||
|  | 				document.head.appendChild(stylesheetLink); | ||||||
|  | 			} else { | ||||||
|  | 				const script = document.createElement('script'); | ||||||
|  | 				script.appendChild(document.createTextNode(scriptData)); | ||||||
|  | 				document.head.appendChild(script); | ||||||
|  | 			} | ||||||
|  | 		}, | ||||||
| 		getFormData: async () => { | 		getFormData: async () => { | ||||||
| 			return getFormData(); | 			return getFormData(); | ||||||
| 		}, | 		}, | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger'; | |||||||
| import { PluginMainProcessApi, PluginWebViewApi } from '../types'; | import { PluginMainProcessApi, PluginWebViewApi } from '../types'; | ||||||
| import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger'; | import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger'; | ||||||
| import WindowMessenger from '@joplin/lib/utils/ipc/WindowMessenger'; | import WindowMessenger from '@joplin/lib/utils/ipc/WindowMessenger'; | ||||||
| import makeSandboxedIframe from './utils/makeSandboxedIframe'; | import makeSandboxedIframe from '@joplin/lib/utils/dom/makeSandboxedIframe'; | ||||||
|  |  | ||||||
| type PluginRecord = { | type PluginRecord = { | ||||||
| 	iframe: HTMLIFrameElement; | 	iframe: HTMLIFrameElement; | ||||||
| @@ -50,7 +50,7 @@ export const runPlugin = ( | |||||||
| 			${pluginScript} | 			${pluginScript} | ||||||
| 		})(); | 		})(); | ||||||
| 	`; | 	`; | ||||||
| 	const backgroundIframe = makeSandboxedIframe(bodyHtml, [initialJavaScript]).iframe; | 	const backgroundIframe = makeSandboxedIframe({ bodyHtml, headHtml: '', scripts: [initialJavaScript] }).iframe; | ||||||
|  |  | ||||||
| 	loadedPlugins[pluginId] = { | 	loadedPlugins[pluginId] = { | ||||||
| 		iframe: backgroundIframe, | 		iframe: backgroundIframe, | ||||||
|   | |||||||
| @@ -1,7 +1,8 @@ | |||||||
| import * as React from 'react'; | import * as React from 'react'; | ||||||
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||||||
| import { PluginHtmlContents, ViewInfo } from '@joplin/lib/services/plugins/reducer'; | import { PluginHtmlContents, ViewInfo } from '@joplin/lib/services/plugins/reducer'; | ||||||
| import ExtendedWebView, { WebViewControl } from '../../ExtendedWebView'; | import ExtendedWebView from '../../ExtendedWebView'; | ||||||
|  | import { WebViewControl } from '../../ExtendedWebView/types'; | ||||||
| import { ViewStyle } from 'react-native'; | import { ViewStyle } from 'react-native'; | ||||||
| import usePlugin from '@joplin/lib/hooks/usePlugin'; | import usePlugin from '@joplin/lib/hooks/usePlugin'; | ||||||
| import shim from '@joplin/lib/shim'; | import shim from '@joplin/lib/shim'; | ||||||
| @@ -46,6 +47,7 @@ const PluginUserWebView = (props: Props) => { | |||||||
| 			setThemeCss: messenger.remoteApi.setThemeCss, | 			setThemeCss: messenger.remoteApi.setThemeCss, | ||||||
| 			getFormData: messenger.remoteApi.getFormData, | 			getFormData: messenger.remoteApi.getFormData, | ||||||
| 			getContentSize: messenger.remoteApi.getContentSize, | 			getContentSize: messenger.remoteApi.getContentSize, | ||||||
|  | 			runScript: messenger.remoteApi.runScript, | ||||||
| 		}); | 		}); | ||||||
| 	}, [messenger, props.setDialogControl]); | 	}, [messenger, props.setDialogControl]); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import { useMemo, RefObject } from 'react'; | import { useMemo, RefObject } from 'react'; | ||||||
| import { DialogMainProcessApi, DialogWebViewApi } from '../../types'; | import { DialogMainProcessApi, DialogWebViewApi } from '../../types'; | ||||||
| import Logger from '@joplin/utils/Logger'; | import Logger from '@joplin/utils/Logger'; | ||||||
| import { WebViewControl } from '../../../ExtendedWebView'; | import { WebViewControl } from '../../../ExtendedWebView/types'; | ||||||
| import createOnLogHander from '../../utils/createOnLogHandler'; | import createOnLogHander from '../../utils/createOnLogHandler'; | ||||||
| import RNToWebViewMessenger from '../../../../utils/ipc/RNToWebViewMessenger'; | import RNToWebViewMessenger from '../../../../utils/ipc/RNToWebViewMessenger'; | ||||||
| import { SerializableData } from '@joplin/lib/utils/ipc/types'; | import { SerializableData } from '@joplin/lib/utils/ipc/types'; | ||||||
|   | |||||||
| @@ -29,8 +29,16 @@ const useWebViewSetup = (props: Props) => { | |||||||
| 				jsPaths.push(resolvedPath); | 				jsPaths.push(resolvedPath); | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		void dialogControl.includeCssFiles(cssPaths); | 		if (shim.mobilePlatform() === 'web') { | ||||||
| 		void dialogControl.includeJsFiles(jsPaths); | 			void (async () => { | ||||||
|  | 				for (const path of [...jsPaths, ...cssPaths]) { | ||||||
|  | 					void dialogControl.runScript(path, await shim.fsDriver().readFile(path, 'utf-8')); | ||||||
|  | 				} | ||||||
|  | 			})(); | ||||||
|  | 		} else { | ||||||
|  | 			void dialogControl.includeCssFiles(cssPaths); | ||||||
|  | 			void dialogControl.includeJsFiles(jsPaths); | ||||||
|  | 		} | ||||||
| 	}, [dialogControl, scriptPaths, props.webViewLoadCount, pluginBaseDir]); | 	}, [dialogControl, scriptPaths, props.webViewLoadCount, pluginBaseDir]); | ||||||
|  |  | ||||||
| 	useEffect(() => { | 	useEffect(() => { | ||||||
|   | |||||||
| @@ -57,6 +57,7 @@ export interface DialogWebViewApi { | |||||||
| 	//       does not reload styles/scripts). | 	//       does not reload styles/scripts). | ||||||
| 	includeCssFiles: (paths: string[])=> Promise<void>; | 	includeCssFiles: (paths: string[])=> Promise<void>; | ||||||
| 	includeJsFiles: (paths: string[])=> Promise<void>; | 	includeJsFiles: (paths: string[])=> Promise<void>; | ||||||
|  | 	runScript: (key: string, content: string)=> Promise<void>; | ||||||
|  |  | ||||||
| 	setThemeCss: (css: string)=> Promise<void>; | 	setThemeCss: (css: string)=> Promise<void>; | ||||||
| 	getFormData: ()=> Promise<SerializableData>; | 	getFormData: ()=> Promise<SerializableData>; | ||||||
|   | |||||||
| @@ -35,6 +35,7 @@ import SectionDescription from './SectionDescription'; | |||||||
| import EnablePluginSupportPage from './plugins/EnablePluginSupportPage'; | import EnablePluginSupportPage from './plugins/EnablePluginSupportPage'; | ||||||
| import getVersionInfoText from '../../../utils/getVersionInfoText'; | import getVersionInfoText from '../../../utils/getVersionInfoText'; | ||||||
| import JoplinCloudConfig, { emailToNoteDescription, emailToNoteLabel } from './JoplinCloudConfig'; | import JoplinCloudConfig, { emailToNoteDescription, emailToNoteLabel } from './JoplinCloudConfig'; | ||||||
|  | import shim from '@joplin/lib/shim'; | ||||||
|  |  | ||||||
| interface ConfigScreenState { | interface ConfigScreenState { | ||||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||||
| @@ -257,29 +258,15 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi | |||||||
| 		return this.state.changedSettingKeys.length > 0; | 		return this.state.changedSettingKeys.length > 0; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	private promptSaveChanges(): Promise<void> { | 	private async promptSaveChanges(): Promise<void> { | ||||||
| 		return new Promise(resolve => { | 		if (this.hasUnsavedChanges()) { | ||||||
| 			if (this.hasUnsavedChanges()) { | 			const response = await shim.showMessageBox(_('There are unsaved changes.'), { | ||||||
| 				const dialogTitle: string|null = null; | 				buttons: [_('Save changes'), _('Discard changes')], | ||||||
| 				Alert.alert( | 			}); | ||||||
| 					dialogTitle, | 			if (response === 0) { | ||||||
| 					_('There are unsaved changes.'), | 				await this.saveButton_press(); | ||||||
| 					[{ |  | ||||||
| 						text: _('Save changes'), |  | ||||||
| 						onPress: async () => { |  | ||||||
| 							await this.saveButton_press(); |  | ||||||
| 							resolve(); |  | ||||||
| 						}, |  | ||||||
| 					}, |  | ||||||
| 					{ |  | ||||||
| 						text: _('Discard changes'), |  | ||||||
| 						onPress: () => resolve(), |  | ||||||
| 					}], |  | ||||||
| 				); |  | ||||||
| 			} else { |  | ||||||
| 				resolve(); |  | ||||||
| 			} | 			} | ||||||
| 		}); | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	private handleNavigateToNewScreen = async (): Promise<boolean> => { | 	private handleNavigateToNewScreen = async (): Promise<boolean> => { | ||||||
|   | |||||||
| @@ -3,18 +3,26 @@ import * as React from 'react'; | |||||||
| import shim from '@joplin/lib/shim'; | import shim from '@joplin/lib/shim'; | ||||||
| import { FunctionComponent, useCallback, useEffect, useState } from 'react'; | import { FunctionComponent, useCallback, useEffect, useState } from 'react'; | ||||||
| import { ConfigScreenStyles } from './configScreenStyles'; | import { ConfigScreenStyles } from './configScreenStyles'; | ||||||
| import { TouchableNativeFeedback, View, Text } from 'react-native'; | import { View, Text } from 'react-native'; | ||||||
| import Setting, { SettingItem } from '@joplin/lib/models/Setting'; | import Setting, { SettingItem } from '@joplin/lib/models/Setting'; | ||||||
| import { openDocumentTree } from '@joplin/react-native-saf-x'; | import { openDocumentTree } from '@joplin/react-native-saf-x'; | ||||||
| import { UpdateSettingValueCallback } from './types'; | import { UpdateSettingValueCallback } from './types'; | ||||||
| import { reg } from '@joplin/lib/registry'; | import { reg } from '@joplin/lib/registry'; | ||||||
|  | import type FsDriverWeb from '../../../utils/fs-driver/fs-driver-rn.web'; | ||||||
|  | import { TouchableRipple } from 'react-native-paper'; | ||||||
|  |  | ||||||
| interface Props { | interface Props { | ||||||
| 	styles: ConfigScreenStyles; | 	styles: ConfigScreenStyles; | ||||||
| 	settingMetadata: SettingItem; | 	settingMetadata: SettingItem; | ||||||
|  | 	mode: 'read'|'readwrite'; | ||||||
| 	updateSettingValue: UpdateSettingValueCallback; | 	updateSettingValue: UpdateSettingValueCallback; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | type ExtendedSelf = (typeof window.self) & { | ||||||
|  | 	showDirectoryPicker: (options: { id: string; mode: string })=> Promise<FileSystemDirectoryHandle>; | ||||||
|  | }; | ||||||
|  | declare const self: ExtendedSelf; | ||||||
|  |  | ||||||
| const FileSystemPathSelector: FunctionComponent<Props> = props => { | const FileSystemPathSelector: FunctionComponent<Props> = props => { | ||||||
| 	const [fileSystemPath, setFileSystemPath] = useState<string>(''); | 	const [fileSystemPath, setFileSystemPath] = useState<string>(''); | ||||||
|  |  | ||||||
| @@ -25,30 +33,42 @@ const FileSystemPathSelector: FunctionComponent<Props> = props => { | |||||||
| 	}, [settingId]); | 	}, [settingId]); | ||||||
|  |  | ||||||
| 	const selectDirectoryButtonPress = useCallback(async () => { | 	const selectDirectoryButtonPress = useCallback(async () => { | ||||||
| 		try { | 		if (shim.mobilePlatform() === 'web') { | ||||||
| 			const doc = await openDocumentTree(true); | 			// Directory picker IDs can't include certain characters. | ||||||
| 			if (doc?.uri) { | 			const pickerId = `setting-${settingId}`.replace(/[^a-zA-Z]/g, '_'); | ||||||
| 				setFileSystemPath(doc.uri); | 			const handle = await self.showDirectoryPicker({ id: pickerId, mode: props.mode }); | ||||||
| 				await props.updateSettingValue(settingId, doc.uri); | 			const fsDriver = shim.fsDriver() as FsDriverWeb; | ||||||
| 			} else { | 			const uri = await fsDriver.mountExternalDirectory(handle, pickerId, props.mode); | ||||||
| 				throw new Error('User cancelled operation'); | 			await props.updateSettingValue(settingId, uri); | ||||||
|  | 			setFileSystemPath(uri); | ||||||
|  | 		} else { | ||||||
|  | 			try { | ||||||
|  | 				const doc = await openDocumentTree(true); | ||||||
|  | 				if (doc?.uri) { | ||||||
|  | 					setFileSystemPath(doc.uri); | ||||||
|  | 					await props.updateSettingValue(settingId, doc.uri); | ||||||
|  | 				} else { | ||||||
|  | 					throw new Error('User cancelled operation'); | ||||||
|  | 				} | ||||||
|  | 			} catch (e) { | ||||||
|  | 				reg.logger().info('Didn\'t pick sync dir: ', e); | ||||||
| 			} | 			} | ||||||
| 		} catch (e) { |  | ||||||
| 			reg.logger().info('Didn\'t pick sync dir: ', e); |  | ||||||
| 		} | 		} | ||||||
| 	}, [props.updateSettingValue, settingId]); | 	}, [props.updateSettingValue, settingId, props.mode]); | ||||||
|  |  | ||||||
| 	// Unsupported on non-Android platforms. | 	// Supported on Android and some versions of Chrome | ||||||
| 	if (!shim.fsDriver().isUsingAndroidSAF()) { | 	const supported = shim.fsDriver().isUsingAndroidSAF() || (shim.mobilePlatform() === 'web' && 'showDirectoryPicker' in self); | ||||||
|  | 	if (!supported) { | ||||||
| 		return null; | 		return null; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	const styleSheet = props.styles.styleSheet; | 	const styleSheet = props.styles.styleSheet; | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<TouchableNativeFeedback | 		<TouchableRipple | ||||||
| 			onPress={selectDirectoryButtonPress} | 			onPress={selectDirectoryButtonPress} | ||||||
| 			style={styleSheet.settingContainer} | 			style={styleSheet.settingContainer} | ||||||
|  | 			role='button' | ||||||
| 		> | 		> | ||||||
| 			<View style={styleSheet.settingContainer}> | 			<View style={styleSheet.settingContainer}> | ||||||
| 				<Text key="label" style={styleSheet.settingText}> | 				<Text key="label" style={styleSheet.settingText}> | ||||||
| @@ -58,7 +78,7 @@ const FileSystemPathSelector: FunctionComponent<Props> = props => { | |||||||
| 					{fileSystemPath} | 					{fileSystemPath} | ||||||
| 				</Text> | 				</Text> | ||||||
| 			</View> | 			</View> | ||||||
| 		</TouchableNativeFeedback> | 		</TouchableRipple> | ||||||
| 	); | 	); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -4,12 +4,12 @@ import Logger from '@joplin/utils/Logger'; | |||||||
| import { FunctionComponent } from 'react'; | import { FunctionComponent } from 'react'; | ||||||
| import shim from '@joplin/lib/shim'; | import shim from '@joplin/lib/shim'; | ||||||
| import { join } from 'path'; | import { join } from 'path'; | ||||||
| import Share from 'react-native-share'; |  | ||||||
| import exportAllFolders from './utils/exportAllFolders'; | import exportAllFolders from './utils/exportAllFolders'; | ||||||
| import { ExportProgressState } from '@joplin/lib/services/interop/types'; | import { ExportProgressState } from '@joplin/lib/services/interop/types'; | ||||||
| import { ConfigScreenStyles } from '../configScreenStyles'; | import { ConfigScreenStyles } from '../configScreenStyles'; | ||||||
| import makeImportExportCacheDirectory from './utils/makeImportExportCacheDirectory'; | import makeImportExportCacheDirectory from './utils/makeImportExportCacheDirectory'; | ||||||
| import TaskButton, { OnProgressCallback, SetAfterCompleteListenerCallback, TaskStatus } from './TaskButton'; | import TaskButton, { OnProgressCallback, SetAfterCompleteListenerCallback, TaskStatus } from './TaskButton'; | ||||||
|  | import shareFile from '../../../../utils/shareFile'; | ||||||
|  |  | ||||||
| const logger = Logger.create('NoteExportButton'); | const logger = Logger.create('NoteExportButton'); | ||||||
|  |  | ||||||
| @@ -37,12 +37,7 @@ const runExportTask = async ( | |||||||
|  |  | ||||||
| 	setAfterCompleteListener(async (success: boolean) => { | 	setAfterCompleteListener(async (success: boolean) => { | ||||||
| 		if (success) { | 		if (success) { | ||||||
| 			await Share.open({ | 			await shareFile(exportTargetPath, 'application/jex'); | ||||||
| 				type: 'application/jex', |  | ||||||
| 				filename: 'export.jex', |  | ||||||
| 				url: `file://${exportTargetPath}`, |  | ||||||
| 				failOnCancel: false, |  | ||||||
| 			}); |  | ||||||
| 		} | 		} | ||||||
| 		await shim.fsDriver().remove(exportTargetPath); | 		await shim.fsDriver().remove(exportTargetPath); | ||||||
| 	}); | 	}); | ||||||
|   | |||||||
| @@ -40,7 +40,7 @@ const runImportTask = async ( | |||||||
| 		await shim.fsDriver().remove(importTargetPath); | 		await shim.fsDriver().remove(importTargetPath); | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| 	const importFiles = await pickDocument(false); | 	const importFiles = await pickDocument({ multiple: false }); | ||||||
| 	if (importFiles.length === 0) { | 	if (importFiles.length === 0) { | ||||||
| 		logger.info('Canceled.'); | 		logger.info('Canceled.'); | ||||||
| 		return { success: false, warnings: [] }; | 		return { success: false, warnings: [] }; | ||||||
| @@ -48,7 +48,7 @@ const runImportTask = async ( | |||||||
|  |  | ||||||
| 	const sourceFileUri = importFiles[0].uri; | 	const sourceFileUri = importFiles[0].uri; | ||||||
| 	const sourceFilePath = Platform.select({ | 	const sourceFilePath = Platform.select({ | ||||||
| 		android: sourceFileUri, | 		default: sourceFileUri, | ||||||
| 		ios: decodeURI(sourceFileUri), | 		ios: decodeURI(sourceFileUri), | ||||||
| 	}); | 	}); | ||||||
| 	await shim.fsDriver().copy(sourceFilePath, importTargetPath); | 	await shim.fsDriver().copy(sourceFilePath, importTargetPath); | ||||||
|   | |||||||
| @@ -1,11 +1,12 @@ | |||||||
| import * as React from 'react'; | import * as React from 'react'; | ||||||
| import { Alert, Text } from 'react-native'; | import { Text } from 'react-native'; | ||||||
| import { _ } from '@joplin/lib/locale'; | import { _ } from '@joplin/lib/locale'; | ||||||
| import { ProgressBar } from 'react-native-paper'; | import { ProgressBar } from 'react-native-paper'; | ||||||
| import { FunctionComponent, useCallback, useState } from 'react'; | import { FunctionComponent, useCallback, useState } from 'react'; | ||||||
| import { ConfigScreenStyles } from '../configScreenStyles'; | import { ConfigScreenStyles } from '../configScreenStyles'; | ||||||
| import SettingsButton from '../SettingsButton'; | import SettingsButton from '../SettingsButton'; | ||||||
| import Logger from '@joplin/utils/Logger'; | import Logger from '@joplin/utils/Logger'; | ||||||
|  | import shim from '@joplin/lib/shim'; | ||||||
|  |  | ||||||
| // Undefined = indeterminate progress | // Undefined = indeterminate progress | ||||||
| export type OnProgressCallback = (progressFraction: number|undefined)=> void; | export type OnProgressCallback = (progressFraction: number|undefined)=> void; | ||||||
| @@ -69,7 +70,10 @@ const TaskButton: FunctionComponent<Props> = props => { | |||||||
| 			} | 			} | ||||||
| 		} catch (error) { | 		} catch (error) { | ||||||
| 			logger.error(`Task ${props.taskName} failed`, error); | 			logger.error(`Task ${props.taskName} failed`, error); | ||||||
| 			Alert.alert(_('Error'), _('Task "%s" failed with error: %s', props.taskName, error.toString())); | 			await shim.showMessageBox(_('Task "%s" failed with error: %s', props.taskName, error.toString()), { | ||||||
|  | 				title: _('Error'), | ||||||
|  | 				buttons: [_('OK')], | ||||||
|  | 			}); | ||||||
| 		} finally { | 		} finally { | ||||||
| 			if (!completedSuccessfully) { | 			if (!completedSuccessfully) { | ||||||
| 				setTaskStatus(TaskStatus.NotStarted); | 				setTaskStatus(TaskStatus.NotStarted); | ||||||
|   | |||||||
| @@ -1,9 +1,8 @@ | |||||||
|  |  | ||||||
| import shim from '@joplin/lib/shim'; | import shim from '@joplin/lib/shim'; | ||||||
| import { CachesDirectoryPath } from 'react-native-fs'; |  | ||||||
|  |  | ||||||
| const makeImportExportCacheDirectory = async () => { | const makeImportExportCacheDirectory = async () => { | ||||||
| 	const targetDir = `${CachesDirectoryPath}/exports`; | 	const targetDir = `${shim.fsDriver().getCacheDirectoryPath()}/exports`; | ||||||
| 	await shim.fsDriver().mkdir(targetDir); | 	await shim.fsDriver().mkdir(targetDir); | ||||||
|  |  | ||||||
| 	return targetDir; | 	return targetDir; | ||||||
|   | |||||||
| @@ -115,9 +115,10 @@ const SettingComponent: React.FunctionComponent<Props> = props => { | |||||||
| 			</View> | 			</View> | ||||||
| 		); | 		); | ||||||
| 	} else if (md.type === Setting.TYPE_STRING) { | 	} else if (md.type === Setting.TYPE_STRING) { | ||||||
| 		if (md.key === 'sync.2.path' && shim.fsDriver().isUsingAndroidSAF()) { | 		if (['sync.2.path', 'plugins.devPluginPaths'].includes(md.key) && (shim.fsDriver().isUsingAndroidSAF() || shim.mobilePlatform() === 'web')) { | ||||||
| 			return ( | 			return ( | ||||||
| 				<FileSystemPathSelector | 				<FileSystemPathSelector | ||||||
|  | 					mode={md.key === 'sync.2.path' ? 'readwrite' : 'read'} | ||||||
| 					styles={props.styles} | 					styles={props.styles} | ||||||
| 					settingMetadata={md} | 					settingMetadata={md} | ||||||
| 					updateSettingValue={props.updateSettingValue} | 					updateSettingValue={props.updateSettingValue} | ||||||
|   | |||||||
| @@ -53,6 +53,7 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => { | |||||||
| 	const settingContainerStyle: ViewStyle = { | 	const settingContainerStyle: ViewStyle = { | ||||||
| 		flex: 1, | 		flex: 1, | ||||||
| 		flexDirection: 'row', | 		flexDirection: 'row', | ||||||
|  | 		flexBasis: 'auto', | ||||||
| 		alignItems: 'center', | 		alignItems: 'center', | ||||||
| 		borderBottomWidth: 1, | 		borderBottomWidth: 1, | ||||||
| 		borderBottomColor: theme.dividerColor, | 		borderBottomColor: theme.dividerColor, | ||||||
| @@ -80,6 +81,7 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => { | |||||||
| 	const sidebarButton: SidebarButtonStyle = { | 	const sidebarButton: SidebarButtonStyle = { | ||||||
| 		height: sidebarButtonHeight, | 		height: sidebarButtonHeight, | ||||||
| 		flex: 1, | 		flex: 1, | ||||||
|  | 		flexBasis: 'auto', | ||||||
| 		flexDirection: 'row', | 		flexDirection: 'row', | ||||||
| 		alignItems: 'center', | 		alignItems: 'center', | ||||||
| 		paddingEnd: theme.marginRight, | 		paddingEnd: theme.marginRight, | ||||||
| @@ -184,6 +186,7 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => { | |||||||
| 			...settingControlStyle, | 			...settingControlStyle, | ||||||
| 			color: undefined, | 			color: undefined, | ||||||
| 			flex: 0, | 			flex: 0, | ||||||
|  | 			flexBasis: 'auto', | ||||||
| 		}, | 		}, | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,26 +2,10 @@ import { _ } from '@joplin/lib/locale'; | |||||||
| import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; | import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; | ||||||
| import * as React from 'react'; | import * as React from 'react'; | ||||||
| import IconButton from '../../../../IconButton'; | import IconButton from '../../../../IconButton'; | ||||||
| import { Alert, Linking, StyleSheet } from 'react-native'; | import { Linking, StyleSheet } from 'react-native'; | ||||||
| import { themeStyle } from '../../../../global-style'; | import { themeStyle } from '../../../../global-style'; | ||||||
| import { useMemo } from 'react'; | import { useCallback, useContext, useMemo } from 'react'; | ||||||
|  | import { DialogContext } from '../../../../DialogManager'; | ||||||
| const onRecommendedPress = () => { |  | ||||||
| 	Alert.alert( |  | ||||||
| 		'', |  | ||||||
| 		_('The Joplin team has vetted this plugin and it meets our standards for security and performance.'), |  | ||||||
| 		[ |  | ||||||
| 			{ |  | ||||||
| 				text: _('Learn more'), |  | ||||||
| 				onPress: () => Linking.openURL('https://github.com/joplin/plugins/blob/master/readme/recommended.md'), |  | ||||||
| 			}, |  | ||||||
| 			{ |  | ||||||
| 				text: _('OK'), |  | ||||||
| 			}, |  | ||||||
| 		], |  | ||||||
| 		{ cancelable: true }, |  | ||||||
| 	); |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| interface Props { | interface Props { | ||||||
| 	themeId: number; | 	themeId: number; | ||||||
| @@ -58,6 +42,24 @@ const useStyles = (themeId: number) => { | |||||||
| const RecommendedBadge: React.FC<Props> = props => { | const RecommendedBadge: React.FC<Props> = props => { | ||||||
| 	const styles = useStyles(props.themeId); | 	const styles = useStyles(props.themeId); | ||||||
|  |  | ||||||
|  | 	const dialogs = useContext(DialogContext); | ||||||
|  | 	const onRecommendedPress = useCallback(() => { | ||||||
|  | 		dialogs.prompt( | ||||||
|  | 			'', | ||||||
|  | 			_('The Joplin team has vetted this plugin and it meets our standards for security and performance.'), | ||||||
|  | 			[ | ||||||
|  | 				{ | ||||||
|  | 					text: _('Learn more'), | ||||||
|  | 					onPress: () => Linking.openURL('https://github.com/joplin/plugins/blob/master/readme/recommended.md'), | ||||||
|  | 				}, | ||||||
|  | 				{ | ||||||
|  | 					text: _('OK'), | ||||||
|  | 				}, | ||||||
|  | 			], | ||||||
|  | 			{ cancelable: true }, | ||||||
|  | 		); | ||||||
|  | 	}, [dialogs]); | ||||||
|  |  | ||||||
| 	if (!props.manifest._recommended || !props.isCompatible) return null; | 	if (!props.manifest._recommended || !props.isCompatible) return null; | ||||||
|  |  | ||||||
| 	return <IconButton | 	return <IconButton | ||||||
|   | |||||||
| @@ -80,9 +80,10 @@ const PluginBox: React.FC<Props> = props => { | |||||||
|  |  | ||||||
| 	const styles = useStyles(props.isCompatible); | 	const styles = useStyles(props.isCompatible); | ||||||
|  |  | ||||||
|  | 	const CardWrapper = props.onShowPluginInfo ? TouchableRipple : View; | ||||||
| 	return ( | 	return ( | ||||||
| 		<TouchableRipple | 		<CardWrapper | ||||||
| 			accessibilityRole='button' | 			accessibilityRole={props.onShowPluginInfo ? 'button' : null} | ||||||
| 			accessible={true} | 			accessible={true} | ||||||
| 			onPress={props.onShowPluginInfo ? onPress : null} | 			onPress={props.onShowPluginInfo ? onPress : null} | ||||||
| 			style={styles.cardContainer} | 			style={styles.cardContainer} | ||||||
| @@ -115,7 +116,7 @@ const PluginBox: React.FC<Props> = props => { | |||||||
| 					{props.onInstall ? installButton : null} | 					{props.onInstall ? installButton : null} | ||||||
| 				</Card.Actions> | 				</Card.Actions> | ||||||
| 			</Card> | 			</Card> | ||||||
| 		</TouchableRipple> | 		</CardWrapper> | ||||||
| 	); | 	); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -112,7 +112,10 @@ const PluginStates: React.FC<Props> = props => { | |||||||
| 				<Button onPress={reloadPluginRepo}>{_('Retry')}</Button> | 				<Button onPress={reloadPluginRepo}>{_('Retry')}</Button> | ||||||
| 			</View>; | 			</View>; | ||||||
| 		} else { | 		} else { | ||||||
| 			return <ProgressBar accessibilityLabel={_('Loading...')} indeterminate={true} />; | 			// The progress bar needs to be wrapped in a View to have the correct height on web. | ||||||
|  | 			return <View> | ||||||
|  | 				<ProgressBar accessibilityLabel={_('Loading...')} indeterminate={true} /> | ||||||
|  | 			</View>; | ||||||
| 		} | 		} | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -36,15 +36,15 @@ const PluginUploadButton: React.FC<Props> = props => { | |||||||
| 	const onInstallFromFile = useCallback(async () => { | 	const onInstallFromFile = useCallback(async () => { | ||||||
| 		const pluginService = PluginService.instance(); | 		const pluginService = PluginService.instance(); | ||||||
|  |  | ||||||
| 		const pluginFiles = await pickDocument(false); | 		const pluginFiles = await pickDocument({ multiple: false }); | ||||||
| 		if (pluginFiles.length === 0) { | 		if (pluginFiles.length === 0) { | ||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
| 		const selectedFile = pluginFiles[0]; | 		const selectedFile = pluginFiles[0]; | ||||||
|  |  | ||||||
| 		const localFilePath = Platform.select({ | 		const localFilePath = Platform.select({ | ||||||
| 			android: selectedFile.uri, |  | ||||||
| 			ios: decodeURI(selectedFile.uri), | 			ios: decodeURI(selectedFile.uri), | ||||||
|  | 			default: selectedFile.uri, | ||||||
| 		}); | 		}); | ||||||
| 		logger.info('Installing plugin from file', localFilePath); | 		logger.info('Installing plugin from file', localFilePath); | ||||||
|  |  | ||||||
| @@ -73,6 +73,8 @@ const PluginUploadButton: React.FC<Props> = props => { | |||||||
| 			logger.info('Copying to', targetFile); | 			logger.info('Copying to', targetFile); | ||||||
|  |  | ||||||
| 			await fsDriver.copy(localFilePath, targetFile); | 			await fsDriver.copy(localFilePath, targetFile); | ||||||
|  | 			logger.debug('Copied. Now installing.'); | ||||||
|  |  | ||||||
| 			const plugin = await pluginService.installPlugin(targetFile); | 			const plugin = await pluginService.installPlugin(targetFile); | ||||||
|  |  | ||||||
| 			const pluginSettings = pluginService.unserializePluginSettings(props.pluginSettings); | 			const pluginSettings = pluginService.unserializePluginSettings(props.pluginSettings); | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; | |||||||
| import { _ } from '@joplin/lib/locale'; | import { _ } from '@joplin/lib/locale'; | ||||||
| import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; | import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; | ||||||
| import { useCallback, useMemo, useState } from 'react'; | import { useCallback, useMemo, useState } from 'react'; | ||||||
| import { FlatList, StyleSheet, View } from 'react-native'; | import { FlatList, Platform, StyleSheet, View } from 'react-native'; | ||||||
| import { TextInput } from 'react-native-paper'; | import { TextInput } from 'react-native-paper'; | ||||||
| import PluginBox, { InstallState } from './PluginBox'; | import PluginBox, { InstallState } from './PluginBox'; | ||||||
| import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; | import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||||
| @@ -143,6 +143,11 @@ const PluginSearch: React.FC<Props> = props => { | |||||||
| 		} | 		} | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
|  | 	// scrollEnabled seems to have a different effect on web, when compared with native: | ||||||
|  | 	// https://github.com/necolas/react-native-web/issues/1042#issuecomment-407157580 | ||||||
|  | 	// When not provided on web, scrolling the parent element doesn't work. | ||||||
|  | 	const scrollEnabled = Platform.OS === 'web'; | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<View style={styles.container}> | 		<View style={styles.container}> | ||||||
| 			<TextInput | 			<TextInput | ||||||
| @@ -159,7 +164,7 @@ const PluginSearch: React.FC<Props> = props => { | |||||||
| 				data={searchResults} | 				data={searchResults} | ||||||
| 				renderItem={renderResult} | 				renderItem={renderResult} | ||||||
| 				keyExtractor={item => item.id} | 				keyExtractor={item => item.id} | ||||||
| 				scrollEnabled={false} | 				scrollEnabled={scrollEnabled} | ||||||
| 			/> | 			/> | ||||||
| 		</View> | 		</View> | ||||||
| 	); | 	); | ||||||
|   | |||||||
| @@ -1,8 +1,8 @@ | |||||||
| import * as React from 'react'; | import * as React from 'react'; | ||||||
|  |  | ||||||
| import { FlatList, View, Text, Button, StyleSheet, Platform, Alert } from 'react-native'; | import { FlatList, View, Text, Button, StyleSheet, Platform } from 'react-native'; | ||||||
| import { connect } from 'react-redux'; | import { connect } from 'react-redux'; | ||||||
| import { reg } from '@joplin/lib/registry.js'; | import { reg } from '@joplin/lib/registry'; | ||||||
| import { ScreenHeader } from '../ScreenHeader'; | import { ScreenHeader } from '../ScreenHeader'; | ||||||
| import time from '@joplin/lib/time'; | import time from '@joplin/lib/time'; | ||||||
| import { themeStyle } from '../global-style'; | import { themeStyle } from '../global-style'; | ||||||
| @@ -11,10 +11,10 @@ import { BaseScreenComponent } from '../base-screen'; | |||||||
| import { _ } from '@joplin/lib/locale'; | import { _ } from '@joplin/lib/locale'; | ||||||
| import { MenuOptionType } from '../ScreenHeader'; | import { MenuOptionType } from '../ScreenHeader'; | ||||||
| import { AppState } from '../../utils/types'; | import { AppState } from '../../utils/types'; | ||||||
| import Share from 'react-native-share'; |  | ||||||
| import { writeTextToCacheFile } from '../../utils/ShareUtils'; | import { writeTextToCacheFile } from '../../utils/ShareUtils'; | ||||||
| import shim from '@joplin/lib/shim'; | import shim from '@joplin/lib/shim'; | ||||||
| import { TextInput } from 'react-native-paper'; | import { TextInput } from 'react-native-paper'; | ||||||
|  | import shareFile from '../../utils/shareFile'; | ||||||
|  |  | ||||||
| const logger = Logger.create('LogScreen'); | const logger = Logger.create('LogScreen'); | ||||||
|  |  | ||||||
| @@ -100,18 +100,12 @@ class LogScreenComponent extends BaseScreenComponent<Props, State> { | |||||||
| 			// Using a .txt file extension causes a "No valid provider found from URL" error | 			// Using a .txt file extension causes a "No valid provider found from URL" error | ||||||
| 			// and blank share sheet on iOS for larger log files (around 200 KiB). | 			// and blank share sheet on iOS for larger log files (around 200 KiB). | ||||||
| 			fileToShare = await writeTextToCacheFile(logData, 'mobile-log.log'); | 			fileToShare = await writeTextToCacheFile(logData, 'mobile-log.log'); | ||||||
|  | 			await shareFile(fileToShare, 'text/plain'); | ||||||
| 			await Share.open({ |  | ||||||
| 				type: 'text/plain', |  | ||||||
| 				filename: 'log.txt', |  | ||||||
| 				url: `file://${fileToShare}`, |  | ||||||
| 				failOnCancel: false, |  | ||||||
| 			}); |  | ||||||
| 		} catch (e) { | 		} catch (e) { | ||||||
| 			logger.error('Unable to share log data:', e); | 			logger.error('Unable to share log data:', e); | ||||||
|  |  | ||||||
| 			// Display a message to the user (e.g. in the case where the user is out of disk space). | 			// Display a message to the user (e.g. in the case where the user is out of disk space). | ||||||
| 			Alert.alert(_('Error'), _('Unable to share log data. Reason: %s', e.toString())); | 			void shim.showMessageBox(_('Error'), _('Unable to share log data. Reason: %s', e.toString())); | ||||||
| 		} finally { | 		} finally { | ||||||
| 			if (fileToShare) { | 			if (fileToShare) { | ||||||
| 				await shim.fsDriver().remove(fileToShare); | 				await shim.fsDriver().remove(fileToShare); | ||||||
| @@ -225,6 +219,7 @@ class LogScreenComponent extends BaseScreenComponent<Props, State> { | |||||||
| 				{this.state.filter !== undefined ? filterInput : null} | 				{this.state.filter !== undefined ? filterInput : null} | ||||||
| 				<FlatList | 				<FlatList | ||||||
| 					data={this.state.logEntries} | 					data={this.state.logEntries} | ||||||
|  | 					initialNumToRender={100} | ||||||
| 					renderItem={this.onRenderLogRow} | 					renderItem={this.onRenderLogRow} | ||||||
| 					keyExtractor={item => { return `${item.id}`; }} | 					keyExtractor={item => { return `${item.id}`; }} | ||||||
| 				/> | 				/> | ||||||
|   | |||||||
| @@ -6,7 +6,6 @@ import UndoRedoService from '@joplin/lib/services/UndoRedoService'; | |||||||
| import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer'; | import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer'; | ||||||
| import checkPermissions from '../../utils/checkPermissions'; | import checkPermissions from '../../utils/checkPermissions'; | ||||||
| import NoteEditor from '../NoteEditor/NoteEditor'; | import NoteEditor from '../NoteEditor/NoteEditor'; | ||||||
| const FileViewer = require('react-native-file-viewer').default; |  | ||||||
| const React = require('react'); | const React = require('react'); | ||||||
| import { Keyboard, View, TextInput, StyleSheet, Linking, Share, NativeSyntheticEvent } from 'react-native'; | import { Keyboard, View, TextInput, StyleSheet, Linking, Share, NativeSyntheticEvent } from 'react-native'; | ||||||
| import { Platform, PermissionsAndroid } from 'react-native'; | import { Platform, PermissionsAndroid } from 'react-native'; | ||||||
| @@ -20,8 +19,8 @@ const Clipboard = require('@react-native-clipboard/clipboard').default; | |||||||
| const md5 = require('md5'); | const md5 = require('md5'); | ||||||
| const { BackButtonService } = require('../../services/back-button.js'); | const { BackButtonService } = require('../../services/back-button.js'); | ||||||
| import NavService, { OnNavigateCallback as OnNavigateCallback } from '@joplin/lib/services/NavService'; | import NavService, { OnNavigateCallback as OnNavigateCallback } from '@joplin/lib/services/NavService'; | ||||||
| import BaseModel, { ModelType } from '@joplin/lib/BaseModel'; | import { ModelType } from '@joplin/lib/BaseModel'; | ||||||
| import ActionButton from '../ActionButton'; | import FloatingActionButton from '../buttons/FloatingActionButton'; | ||||||
| const { fileExtension, safeFileExtension } = require('@joplin/lib/path-utils'); | const { fileExtension, safeFileExtension } = require('@joplin/lib/path-utils'); | ||||||
| import * as mimeUtils from '@joplin/lib/mime-utils'; | import * as mimeUtils from '@joplin/lib/mime-utils'; | ||||||
| import ScreenHeader, { MenuOptionType } from '../ScreenHeader'; | import ScreenHeader, { MenuOptionType } from '../ScreenHeader'; | ||||||
| @@ -62,7 +61,7 @@ import pickDocument from '../../utils/pickDocument'; | |||||||
| import debounce from '../../utils/debounce'; | import debounce from '../../utils/debounce'; | ||||||
| import { focus } from '@joplin/lib/utils/focusHandler'; | import { focus } from '@joplin/lib/utils/focusHandler'; | ||||||
| import CommandService from '@joplin/lib/services/CommandService'; | import CommandService from '@joplin/lib/services/CommandService'; | ||||||
| import * as urlUtils from '@joplin/lib/urlUtils'; | import { ResourceInfo } from '../NoteBodyViewer/hooks/useRerenderHandler'; | ||||||
| import getImageDimensions from '../../utils/image/getImageDimensions'; | import getImageDimensions from '../../utils/image/getImageDimensions'; | ||||||
| import resizeImage from '../../utils/image/resizeImage'; | import resizeImage from '../../utils/image/resizeImage'; | ||||||
|  |  | ||||||
| @@ -105,7 +104,7 @@ interface State { | |||||||
| 	showImageEditor: boolean; | 	showImageEditor: boolean; | ||||||
| 	imageEditorResource: ResourceEntity; | 	imageEditorResource: ResourceEntity; | ||||||
| 	imageEditorResourceFilepath: string; | 	imageEditorResourceFilepath: string; | ||||||
| 	noteResources: Record<string, ResourceEntity>; | 	noteResources: Record<string, ResourceInfo>; | ||||||
| 	newAndNoTitleChangeNoteId: boolean|null; | 	newAndNoTitleChangeNoteId: boolean|null; | ||||||
|  |  | ||||||
| 	HACK_webviewLoadingState: number; | 	HACK_webviewLoadingState: number; | ||||||
| @@ -269,35 +268,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | |||||||
|  |  | ||||||
| 		this.onJoplinLinkClick_ = async (msg: string) => { | 		this.onJoplinLinkClick_ = async (msg: string) => { | ||||||
| 			try { | 			try { | ||||||
| 				const resourceUrlInfo = urlUtils.parseResourceUrl(msg); | 				await CommandService.instance().execute('openItem', msg); | ||||||
| 				if (resourceUrlInfo) { |  | ||||||
| 					const itemId = resourceUrlInfo.itemId; |  | ||||||
| 					const item = await BaseItem.loadItemById(itemId); |  | ||||||
| 					if (!item) throw new Error(_('No item with ID %s', itemId)); |  | ||||||
|  |  | ||||||
| 					if (item.type_ === BaseModel.TYPE_NOTE) { |  | ||||||
| 						this.props.dispatch({ |  | ||||||
| 							type: 'NAV_GO', |  | ||||||
| 							routeName: 'Note', |  | ||||||
| 							noteId: item.id, |  | ||||||
| 							noteHash: resourceUrlInfo.hash, |  | ||||||
| 						}); |  | ||||||
| 					} else if (item.type_ === BaseModel.TYPE_RESOURCE) { |  | ||||||
| 						if (!(await Resource.isReady(item))) throw new Error(_('This attachment is not downloaded or not decrypted yet.')); |  | ||||||
|  |  | ||||||
| 						const resourcePath = Resource.fullPath(item); |  | ||||||
| 						logger.info(`Opening resource: ${resourcePath}`); |  | ||||||
| 						await FileViewer.open(resourcePath); |  | ||||||
| 					} else { |  | ||||||
| 						throw new Error(_('The Joplin mobile app does not currently support this type of link: %s', BaseModel.modelTypeToName(item.type_))); |  | ||||||
| 					} |  | ||||||
| 				} else { |  | ||||||
| 					if (msg.indexOf('file://') === 0) { |  | ||||||
| 						throw new Error(_('Links with protocol "%s" are not supported', 'file://')); |  | ||||||
| 					} else { |  | ||||||
| 						await Linking.openURL(msg); |  | ||||||
| 					} |  | ||||||
| 				} |  | ||||||
| 			} catch (error) { | 			} catch (error) { | ||||||
| 				dialogs.error(this, error.message); | 				dialogs.error(this, error.message); | ||||||
| 			} | 			} | ||||||
| @@ -460,6 +431,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | |||||||
| 		styles.titleContainer = { | 		styles.titleContainer = { | ||||||
| 			flex: 0, | 			flex: 0, | ||||||
| 			flexDirection: 'row', | 			flexDirection: 'row', | ||||||
|  | 			flexBasis: 'auto', | ||||||
| 			paddingLeft: theme.marginLeft, | 			paddingLeft: theme.marginLeft, | ||||||
| 			paddingRight: theme.marginRight, | 			paddingRight: theme.marginRight, | ||||||
| 			borderBottomColor: theme.dividerColor, | 			borderBottomColor: theme.dividerColor, | ||||||
| @@ -493,6 +465,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | |||||||
|  |  | ||||||
| 	public async requestGeoLocationPermissions() { | 	public async requestGeoLocationPermissions() { | ||||||
| 		if (!Setting.value('trackLocation')) return; | 		if (!Setting.value('trackLocation')) return; | ||||||
|  | 		if (Platform.OS === 'web') return; | ||||||
|  |  | ||||||
| 		const response = await checkPermissions(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, { | 		const response = await checkPermissions(PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION, { | ||||||
| 			message: _('In order to associate a geo-location with the note, the app needs your permission to access your location.\n\nYou may turn off this option at any time in the Configuration screen.'), | 			message: _('In order to associate a geo-location with the note, the app needs your permission to access your location.\n\nYou may turn off this option at any time in the Configuration screen.'), | ||||||
| @@ -670,7 +643,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	private async pickDocuments() { | 	private async pickDocuments() { | ||||||
| 		const result = await pickDocument(true); | 		const result = await pickDocument({ multiple: true }); | ||||||
| 		return result; | 		return result; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -726,8 +699,8 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | |||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		const localFilePath = Platform.select({ | 		const localFilePath = Platform.select({ | ||||||
| 			android: pickerResponse.uri, |  | ||||||
| 			ios: decodeURI(pickerResponse.uri), | 			ios: decodeURI(pickerResponse.uri), | ||||||
|  | 			default: pickerResponse.uri, | ||||||
| 		}); | 		}); | ||||||
|  |  | ||||||
| 		let mimeType = pickerResponse.type; | 		let mimeType = pickerResponse.type; | ||||||
| @@ -849,8 +822,15 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	private takePhoto_onPress() { | 	private async takePhoto_onPress() { | ||||||
| 		this.setState({ showCamera: true }); | 		if (Platform.OS === 'web') { | ||||||
|  | 			const response = await pickDocument({ multiple: true, preferCamera: true }); | ||||||
|  | 			for (const asset of response) { | ||||||
|  | 				await this.attachFile(asset, 'image'); | ||||||
|  | 			} | ||||||
|  | 		} else { | ||||||
|  | 			this.setState({ showCamera: true }); | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||||
| @@ -994,14 +974,20 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	public async onAlarmDialogAccept(date: Date) { | 	public async onAlarmDialogAccept(date: Date) { | ||||||
| 		const response = await checkPermissions(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS); | 		if (Platform.OS === 'android') { | ||||||
|  | 			const response = await checkPermissions(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS); | ||||||
|  |  | ||||||
| 		// The POST_NOTIFICATIONS permission isn't supported on Android API < 33. | 			// The POST_NOTIFICATIONS permission isn't supported on Android API < 33. | ||||||
| 		// (If unsupported, returns NEVER_ASK_AGAIN). | 			// (If unsupported, returns NEVER_ASK_AGAIN). | ||||||
| 		// On earlier releases, notifications should work without this permission. | 			// On earlier releases, notifications should work without this permission. | ||||||
| 		if (response === PermissionsAndroid.RESULTS.DENIED) { | 			if (response === PermissionsAndroid.RESULTS.DENIED) { | ||||||
| 			logger.warn('POST_NOTIFICATIONS permission was not granted'); | 				logger.warn('POST_NOTIFICATIONS permission was not granted'); | ||||||
| 			return; | 				return; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if (Platform.OS === 'web') { | ||||||
|  | 			alert('Warning: The due-date has been saved, but showing notifications is not supported by Joplin Web.'); | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		const newNote = { ...this.state.note }; | 		const newNote = { ...this.state.note }; | ||||||
| @@ -1226,13 +1212,16 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | |||||||
| 			}); | 			}); | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		output.push({ | 		const shareSupported = Platform.OS !== 'web' || !!navigator.share; | ||||||
| 			title: _('Share'), | 		if (shareSupported) { | ||||||
| 			onPress: () => { | 			output.push({ | ||||||
| 				void this.share_onPress(); | 				title: _('Share'), | ||||||
| 			}, | 				onPress: () => { | ||||||
| 			disabled: readOnly, | 					void this.share_onPress(); | ||||||
| 		}); | 				}, | ||||||
|  | 				disabled: readOnly, | ||||||
|  | 			}); | ||||||
|  | 		} | ||||||
|  |  | ||||||
| 		// Voice typing is enabled only for French language and on Android for now | 		// Voice typing is enabled only for French language and on Android for now | ||||||
| 		if (voskEnabled && shim.mobilePlatform() === 'android' && isSupportedLanguage(currentLocale())) { | 		if (voskEnabled && shim.mobilePlatform() === 'android' && isSupportedLanguage(currentLocale())) { | ||||||
| @@ -1270,12 +1259,16 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | |||||||
| 					this.copyMarkdownLink_onPress(); | 					this.copyMarkdownLink_onPress(); | ||||||
| 				}, | 				}, | ||||||
| 			}); | 			}); | ||||||
| 			output.push({ |  | ||||||
| 				title: _('Copy external link'), | 			// External links are not supported on web. | ||||||
| 				onPress: () => { | 			if (Platform.OS !== 'web') { | ||||||
| 					this.copyExternalLink_onPress(); | 				output.push({ | ||||||
| 				}, | 					title: _('Copy external link'), | ||||||
| 			}); | 					onPress: () => { | ||||||
|  | 						this.copyExternalLink_onPress(); | ||||||
|  | 					}, | ||||||
|  | 				}); | ||||||
|  | 			} | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		output.push({ | 		output.push({ | ||||||
| @@ -1584,7 +1577,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | |||||||
|  |  | ||||||
| 			if (this.state.mode === 'edit') return null; | 			if (this.state.mode === 'edit') return null; | ||||||
|  |  | ||||||
| 			return <ActionButton mainButton={editButton} dispatch={this.props.dispatch} />; | 			return <FloatingActionButton mainButton={editButton} dispatch={this.props.dispatch} />; | ||||||
| 		}; | 		}; | ||||||
|  |  | ||||||
| 		// Save button is not really needed anymore with the improved save logic | 		// Save button is not really needed anymore with the improved save logic | ||||||
|   | |||||||
| @@ -10,7 +10,7 @@ import Setting from '@joplin/lib/models/Setting'; | |||||||
| import { themeStyle } from '../global-style'; | import { themeStyle } from '../global-style'; | ||||||
| import { ScreenHeader } from '../ScreenHeader'; | import { ScreenHeader } from '../ScreenHeader'; | ||||||
| import { _ } from '@joplin/lib/locale'; | import { _ } from '@joplin/lib/locale'; | ||||||
| import ActionButton from '../ActionButton'; | import ActionButton from '../buttons/FloatingActionButton'; | ||||||
| const { dialogs } = require('../../utils/dialogs.js'); | const { dialogs } = require('../../utils/dialogs.js'); | ||||||
| const DialogBox = require('react-native-dialogbox').default; | const DialogBox = require('react-native-dialogbox').default; | ||||||
| const { BaseScreenComponent } = require('../base-screen'); | const { BaseScreenComponent } = require('../base-screen'); | ||||||
| @@ -18,6 +18,7 @@ const { BackButtonService } = require('../../services/back-button.js'); | |||||||
| import { AppState } from '../../utils/types'; | import { AppState } from '../../utils/types'; | ||||||
| import { NoteEntity } from '@joplin/lib/services/database/types'; | import { NoteEntity } from '@joplin/lib/services/database/types'; | ||||||
| import { itemIsInTrash } from '@joplin/lib/services/trash'; | import { itemIsInTrash } from '@joplin/lib/services/trash'; | ||||||
|  | import AccessibleView from '../accessibility/AccessibleView'; | ||||||
|  |  | ||||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||||
| class NotesScreenComponent extends BaseScreenComponent<any> { | class NotesScreenComponent extends BaseScreenComponent<any> { | ||||||
| @@ -264,16 +265,13 @@ class NotesScreenComponent extends BaseScreenComponent<any> { | |||||||
| 		const actionButtonComp = this.props.noteSelectionEnabled || !this.props.visible ? null : makeActionButtonComp(); | 		const actionButtonComp = this.props.noteSelectionEnabled || !this.props.visible ? null : makeActionButtonComp(); | ||||||
|  |  | ||||||
| 		// Ensure that screen readers can't focus the notes list when it isn't visible. | 		// Ensure that screen readers can't focus the notes list when it isn't visible. | ||||||
| 		// accessibilityElementsHidden is used on iOS and importantForAccessibility is used |  | ||||||
| 		// on Android. |  | ||||||
| 		const accessibilityHidden = !this.props.visible; | 		const accessibilityHidden = !this.props.visible; | ||||||
|  |  | ||||||
| 		return ( | 		return ( | ||||||
| 			<View | 			<AccessibleView | ||||||
| 				style={rootStyle} | 				style={rootStyle} | ||||||
|  |  | ||||||
| 				accessibilityElementsHidden={accessibilityHidden} | 				inert={accessibilityHidden} | ||||||
| 				importantForAccessibility={accessibilityHidden ? 'no-hide-descendants' : undefined} |  | ||||||
| 			> | 			> | ||||||
| 				<ScreenHeader title={iconString + title} showBackButton={false} parentComponent={thisComp} sortButton_press={this.sortButton_press} folderPickerOptions={this.folderPickerOptions()} showSearchButton={true} showSideMenuButton={true} /> | 				<ScreenHeader title={iconString + title} showBackButton={false} parentComponent={thisComp} sortButton_press={this.sortButton_press} folderPickerOptions={this.folderPickerOptions()} showSearchButton={true} showSideMenuButton={true} /> | ||||||
| 				<NoteList /> | 				<NoteList /> | ||||||
| @@ -284,7 +282,7 @@ class NotesScreenComponent extends BaseScreenComponent<any> { | |||||||
| 						this.dialogbox = dialogbox; | 						this.dialogbox = dialogbox; | ||||||
| 					}} | 					}} | ||||||
| 				/> | 				/> | ||||||
| 			</View> | 			</AccessibleView> | ||||||
| 		); | 		); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -153,7 +153,7 @@ const EncryptionConfigScreen = (props: Props) => { | |||||||
| 		}); | 		}); | ||||||
|  |  | ||||||
| 		return ( | 		return ( | ||||||
| 			<View style={{ flex: 1, borderColor: theme.dividerColor, borderWidth: 1, padding: 10, marginTop: 10, marginBottom: 10 }}> | 			<View style={{ flex: 1, flexBasis: 'auto', borderColor: theme.dividerColor, borderWidth: 1, padding: 10, marginTop: 10, marginBottom: 10 }}> | ||||||
| 				<View>{messageComps}</View> | 				<View>{messageComps}</View> | ||||||
| 				<Text style={styles.normalText}>{_('Password:')}</Text> | 				<Text style={styles.normalText}>{_('Password:')}</Text> | ||||||
| 				<TextInput | 				<TextInput | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ const { Button } = require('react-native'); | |||||||
| const { WebView } = require('react-native-webview'); | const { WebView } = require('react-native-webview'); | ||||||
| const { connect } = require('react-redux'); | const { connect } = require('react-redux'); | ||||||
| const { ScreenHeader } = require('../ScreenHeader'); | const { ScreenHeader } = require('../ScreenHeader'); | ||||||
| const { reg } = require('@joplin/lib/registry.js'); | const { reg } = require('@joplin/lib/registry'); | ||||||
| const { _ } = require('@joplin/lib/locale'); | const { _ } = require('@joplin/lib/locale'); | ||||||
| const { BaseScreenComponent } = require('../base-screen'); | const { BaseScreenComponent } = require('../base-screen'); | ||||||
| const parseUri = require('@joplin/lib/parseUri'); | const parseUri = require('@joplin/lib/parseUri'); | ||||||
|   | |||||||
| @@ -48,6 +48,7 @@ class StatusScreenComponent extends BaseScreenComponent<Props, State> { | |||||||
| 			}, | 			}, | ||||||
| 			actionButton: { | 			actionButton: { | ||||||
| 				flex: 0, | 				flex: 0, | ||||||
|  | 				flexBasis: 'auto', | ||||||
| 				marginLeft: 2, | 				marginLeft: 2, | ||||||
| 				marginRight: 2, | 				marginRight: 2, | ||||||
| 			}, | 			}, | ||||||
|   | |||||||
| @@ -25,6 +25,7 @@ class SideMenuContentNoteComponent extends Component { | |||||||
| 			}, | 			}, | ||||||
| 			button: { | 			button: { | ||||||
| 				flex: 1, | 				flex: 1, | ||||||
|  | 				flexBasis: 'auto', | ||||||
| 				flexDirection: 'row', | 				flexDirection: 'row', | ||||||
| 				height: 36, | 				height: 36, | ||||||
| 				alignItems: 'center', | 				alignItems: 'center', | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| const React = require('react'); | const React = require('react'); | ||||||
| import { useMemo, useEffect, useCallback } from 'react'; | import { useMemo, useEffect, useCallback, useContext } from 'react'; | ||||||
| const { Easing, Animated, TouchableOpacity, Text, StyleSheet, ScrollView, View, Alert, Image } = require('react-native'); | const { Easing, Animated, TouchableOpacity, Text, StyleSheet, ScrollView, View, Image } = require('react-native'); | ||||||
| const { connect } = require('react-redux'); | const { connect } = require('react-redux'); | ||||||
| const Icon = require('react-native-vector-icons/Ionicons').default; | const Icon = require('react-native-vector-icons/Ionicons').default; | ||||||
| import Folder from '@joplin/lib/models/Folder'; | import Folder from '@joplin/lib/models/Folder'; | ||||||
| @@ -18,6 +18,9 @@ import { getTrashFolderIcon, getTrashFolderId } from '@joplin/lib/services/trash | |||||||
| import restoreItems from '@joplin/lib/services/trash/restoreItems'; | import restoreItems from '@joplin/lib/services/trash/restoreItems'; | ||||||
| import emptyTrash from '@joplin/lib/services/trash/emptyTrash'; | import emptyTrash from '@joplin/lib/services/trash/emptyTrash'; | ||||||
| import { ModelType } from '@joplin/lib/BaseModel'; | import { ModelType } from '@joplin/lib/BaseModel'; | ||||||
|  | import { DialogContext } from './DialogManager'; | ||||||
|  | import AccessibleView from './accessibility/AccessibleView'; | ||||||
|  | const { TouchableRipple } = require('react-native-paper'); | ||||||
| const { substrWithEllipsis } = require('@joplin/lib/string-utils'); | const { substrWithEllipsis } = require('@joplin/lib/string-utils'); | ||||||
|  |  | ||||||
| interface Props { | interface Props { | ||||||
| @@ -71,6 +74,7 @@ const SideMenuContentComponent = (props: Props) => { | |||||||
| 			button: { | 			button: { | ||||||
| 				flex: 1, | 				flex: 1, | ||||||
| 				flexDirection: 'row', | 				flexDirection: 'row', | ||||||
|  | 				flexBasis: 'auto', | ||||||
| 				height: 36, | 				height: 36, | ||||||
| 				alignItems: 'center', | 				alignItems: 'center', | ||||||
| 				paddingLeft: theme.marginLeft, | 				paddingLeft: theme.marginLeft, | ||||||
| @@ -144,6 +148,8 @@ const SideMenuContentComponent = (props: Props) => { | |||||||
| 		}); | 		}); | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
|  | 	const dialogs = useContext(DialogContext); | ||||||
|  |  | ||||||
| 	const folder_longPress = async (folderOrAll: FolderEntity | string) => { | 	const folder_longPress = async (folderOrAll: FolderEntity | string) => { | ||||||
| 		if (folderOrAll === 'all') return; | 		if (folderOrAll === 'all') return; | ||||||
|  |  | ||||||
| @@ -156,7 +162,7 @@ const SideMenuContentComponent = (props: Props) => { | |||||||
| 			menuItems.push({ | 			menuItems.push({ | ||||||
| 				text: _('Empty trash'), | 				text: _('Empty trash'), | ||||||
| 				onPress: async () => { | 				onPress: async () => { | ||||||
| 					Alert.alert('', _('This will permanently delete all items in the trash. Continue?'), [ | 					dialogs.prompt('', _('This will permanently delete all items in the trash. Continue?'), [ | ||||||
| 						{ | 						{ | ||||||
| 							text: _('Empty trash'), | 							text: _('Empty trash'), | ||||||
| 							onPress: async () => { | 							onPress: async () => { | ||||||
| @@ -206,7 +212,7 @@ const SideMenuContentComponent = (props: Props) => { | |||||||
| 		} else { | 		} else { | ||||||
| 			const generateFolderDeletion = () => { | 			const generateFolderDeletion = () => { | ||||||
| 				const folderDeletion = (message: string) => { | 				const folderDeletion = (message: string) => { | ||||||
| 					Alert.alert('', message, [ | 					dialogs.prompt('', message, [ | ||||||
| 						{ | 						{ | ||||||
| 							text: _('OK'), | 							text: _('OK'), | ||||||
| 							onPress: () => { | 							onPress: () => { | ||||||
| @@ -255,13 +261,10 @@ const SideMenuContentComponent = (props: Props) => { | |||||||
| 			style: 'cancel', | 			style: 'cancel', | ||||||
| 		}); | 		}); | ||||||
|  |  | ||||||
| 		Alert.alert( | 		dialogs.prompt( | ||||||
| 			'', | 			'', | ||||||
| 			_('Notebook: %s', folder.title), | 			_('Notebook: %s', folder.title), | ||||||
| 			menuItems, | 			menuItems, | ||||||
| 			{ |  | ||||||
| 				cancelable: false, |  | ||||||
| 			}, |  | ||||||
| 		); | 		); | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| @@ -400,6 +403,7 @@ const SideMenuContentComponent = (props: Props) => { | |||||||
| 		const folderButtonStyle: any = { | 		const folderButtonStyle: any = { | ||||||
| 			flex: 1, | 			flex: 1, | ||||||
| 			flexDirection: 'row', | 			flexDirection: 'row', | ||||||
|  | 			flexBasis: 'auto', | ||||||
| 			height: 36, | 			height: 36, | ||||||
| 			alignItems: 'center', | 			alignItems: 'center', | ||||||
| 			paddingRight: theme.marginRight, | 			paddingRight: theme.marginRight, | ||||||
| @@ -438,14 +442,19 @@ const SideMenuContentComponent = (props: Props) => { | |||||||
|  |  | ||||||
| 		return ( | 		return ( | ||||||
| 			<View key={folder.id} style={{ flex: 1, flexDirection: 'row' }}> | 			<View key={folder.id} style={{ flex: 1, flexDirection: 'row' }}> | ||||||
| 				<TouchableOpacity | 				<TouchableRipple | ||||||
| 					style={{ flex: 1 }} | 					style={{ flex: 1, flexBasis: 'auto' }} | ||||||
| 					onPress={() => { | 					onPress={() => { | ||||||
| 						folder_press(folder); | 						folder_press(folder); | ||||||
| 					}} | 					}} | ||||||
| 					onLongPress={() => { | 					onLongPress={() => { | ||||||
| 						void folder_longPress(folder); | 						void folder_longPress(folder); | ||||||
| 					}} | 					}} | ||||||
|  | 					onContextMenu={(event: Event) => { // web only | ||||||
|  | 						event.preventDefault(); | ||||||
|  | 						void folder_longPress(folder); | ||||||
|  | 					}} | ||||||
|  | 					role='button' | ||||||
| 				> | 				> | ||||||
| 					<View style={folderButtonStyle}> | 					<View style={folderButtonStyle}> | ||||||
| 						{renderFolderIcon(folder.id, theme, folderIcon)} | 						{renderFolderIcon(folder.id, theme, folderIcon)} | ||||||
| @@ -453,7 +462,7 @@ const SideMenuContentComponent = (props: Props) => { | |||||||
| 							{Folder.displayTitle(folder)} | 							{Folder.displayTitle(folder)} | ||||||
| 						</Text> | 						</Text> | ||||||
| 					</View> | 					</View> | ||||||
| 				</TouchableOpacity> | 				</TouchableRipple> | ||||||
| 				{iconWrapper} | 				{iconWrapper} | ||||||
| 			</View> | 			</View> | ||||||
| 		); | 		); | ||||||
| @@ -461,7 +470,7 @@ const SideMenuContentComponent = (props: Props) => { | |||||||
|  |  | ||||||
| 	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | 	// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||||
| 	const renderSidebarButton = (key: string, title: string, iconName: string, onPressHandler: Function = null, selected = false) => { | 	const renderSidebarButton = (key: string, title: string, iconName: string, onPressHandler: Function = null, selected = false) => { | ||||||
| 		let icon = <Icon name={iconName} style={styles_.sidebarIcon} />; | 		let icon = <Icon name={iconName} style={styles_.sidebarIcon} aria-hidden={true} />; | ||||||
|  |  | ||||||
| 		if (key === 'synchronize_button') { | 		if (key === 'synchronize_button') { | ||||||
| 			icon = <Animated.View style={{ transform: [{ rotate: syncIconRotation }] }}>{icon}</Animated.View>; | 			icon = <Animated.View style={{ transform: [{ rotate: syncIconRotation }] }}>{icon}</Animated.View>; | ||||||
| @@ -477,7 +486,7 @@ const SideMenuContentComponent = (props: Props) => { | |||||||
| 		if (!onPressHandler) return content; | 		if (!onPressHandler) return content; | ||||||
|  |  | ||||||
| 		return ( | 		return ( | ||||||
| 			<TouchableOpacity key={key} onPress={onPressHandler}> | 			<TouchableOpacity key={key} onPress={onPressHandler} role='button'> | ||||||
| 				{content} | 				{content} | ||||||
| 			</TouchableOpacity> | 			</TouchableOpacity> | ||||||
| 		); | 		); | ||||||
| @@ -543,7 +552,7 @@ const SideMenuContentComponent = (props: Props) => { | |||||||
| 			); | 			); | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		return <View style={{ flex: 0, flexDirection: 'column', paddingBottom: theme.marginBottom }}>{items}</View>; | 		return <View style={{ flex: 0, flexDirection: 'column', flexBasis: 'auto', paddingBottom: theme.marginBottom }}>{items}</View>; | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	let items = []; | 	let items = []; | ||||||
| @@ -587,15 +596,13 @@ const SideMenuContentComponent = (props: Props) => { | |||||||
| 		opacity: isHidden ? 0.5 : undefined, | 		opacity: isHidden ? 0.5 : undefined, | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	// Note: iOS uses accessibilityElementsHidden and Android uses importantForAccessibility |  | ||||||
| 	//       to hide elements from the screenreader. |  | ||||||
|  |  | ||||||
| 	return ( | 	return ( | ||||||
| 		<View | 		<AccessibleView | ||||||
| 			style={style} | 			style={style} | ||||||
|  |  | ||||||
| 			accessibilityElementsHidden={isHidden} | 			// Accessibility, keyboard, and touch hidden. | ||||||
| 			importantForAccessibility={isHidden ? 'no-hide-descendants' : undefined} | 			inert={isHidden} | ||||||
|  | 			refocusCounter={isHidden ? undefined : 1} | ||||||
| 		> | 		> | ||||||
| 			<View style={{ flex: 1, opacity: props.opacity }}> | 			<View style={{ flex: 1, opacity: props.opacity }}> | ||||||
| 				<ScrollView scrollsToTop={false} style={styles_.menu}> | 				<ScrollView scrollsToTop={false} style={styles_.menu}> | ||||||
| @@ -603,7 +610,7 @@ const SideMenuContentComponent = (props: Props) => { | |||||||
| 				</ScrollView> | 				</ScrollView> | ||||||
| 				{renderBottomPanel()} | 				{renderBottomPanel()} | ||||||
| 			</View> | 			</View> | ||||||
| 		</View> | 		</AccessibleView> | ||||||
| 	); | 	); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -7,6 +7,9 @@ const tasks = { | |||||||
| 	encodeAssets: { | 	encodeAssets: { | ||||||
| 		fn: require('./tools/encodeAssets'), | 		fn: require('./tools/encodeAssets'), | ||||||
| 	}, | 	}, | ||||||
|  | 	copyWebAssets: { | ||||||
|  | 		fn: require('./tools/copyAssets').default, | ||||||
|  | 	}, | ||||||
| 	...injectedJsGulpTasks, | 	...injectedJsGulpTasks, | ||||||
| 	podInstall: { | 	podInstall: { | ||||||
| 		fn: require('./tools/podInstall'), | 		fn: require('./tools/podInstall'), | ||||||
| @@ -37,6 +40,7 @@ gulp.task('watchInjectedJs', gulp.series( | |||||||
|  |  | ||||||
| gulp.task('build', gulp.series( | gulp.task('build', gulp.series( | ||||||
| 	'buildInjectedJs', | 	'buildInjectedJs', | ||||||
|  | 	'copyWebAssets', | ||||||
| 	'encodeAssets', | 	'encodeAssets', | ||||||
| 	'podInstall', | 	'podInstall', | ||||||
| )); | )); | ||||||
|   | |||||||
							
								
								
									
										69
									
								
								packages/app-mobile/index.web.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								packages/app-mobile/index.web.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | |||||||
|  | import { AppRegistry } from 'react-native'; | ||||||
|  | import Root from './root'; | ||||||
|  |  | ||||||
|  | require('./web/rnVectorIconsSetup.js'); | ||||||
|  |  | ||||||
|  | // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Necessary until Root doesn't extend `any` | ||||||
|  | AppRegistry.registerComponent('Joplin', () => Root as any); | ||||||
|  |  | ||||||
|  | // Fill properties not yet available in the TypeScript DOM types. | ||||||
|  | interface ExtendedNavigator extends Navigator { | ||||||
|  | 	virtualKeyboard?: { overlaysContent: boolean }; | ||||||
|  | } | ||||||
|  | declare const navigator: ExtendedNavigator; | ||||||
|  |  | ||||||
|  | // Should prevent the browser from auto-deleting background data. | ||||||
|  | const requestPersistentStorage = async () => { | ||||||
|  | 	if (!(await navigator.storage.persisted())) { | ||||||
|  | 		await navigator.storage.persist(); | ||||||
|  | 	} | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const keepAppAboveKeyboard = () => { | ||||||
|  | 	let updateQueued = false; | ||||||
|  |  | ||||||
|  | 	// This prevents the virtual keyboard from covering content near the bottom of the screen | ||||||
|  | 	// (e.g. the markdown toolbar) on both iOS and Android. As of June 2024, this can't be | ||||||
|  | 	// done with the Virtual Keyboard API on iOS. | ||||||
|  | 	const handleViewportChange = () => { | ||||||
|  | 		if (updateQueued) return; | ||||||
|  |  | ||||||
|  | 		updateQueued = true; | ||||||
|  | 		requestAnimationFrame(() => { | ||||||
|  | 			updateQueued = false; | ||||||
|  |  | ||||||
|  | 			// The visual viewport changes as the user zooms in and out. Only adjust the body's height | ||||||
|  | 			// when the user's (pinch/touchpad) zoom level is roughly 100% or less. | ||||||
|  | 			if (window.visualViewport.scale <= 1.01) { | ||||||
|  | 				document.body.style.height = `${window.visualViewport.height}px`; | ||||||
|  |  | ||||||
|  | 				// Additional scroll space can also be added by the browser when focusing a text input (e.g. | ||||||
|  | 				// the markdown editor). Make sure that the top of the editor is still visible: | ||||||
|  | 				document.scrollingElement.scrollTop = 0; | ||||||
|  | 			} else { | ||||||
|  | 				document.body.style.height = ''; | ||||||
|  | 			} | ||||||
|  | 		}); | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	if (window.visualViewport) { | ||||||
|  | 		window.visualViewport.addEventListener('resize', handleViewportChange); | ||||||
|  | 	} | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | addEventListener('DOMContentLoaded', async () => { | ||||||
|  | 	if (window.crossOriginIsolated === false) { | ||||||
|  | 		// Currently, reloading should be handled by serviceWorker.ts -- this change is left for | ||||||
|  | 		// debugging purposes. | ||||||
|  | 		document.body.prepend( | ||||||
|  | 			document.createTextNode('Warning: crossOriginIsolated is false. SharedArrayBuffer and similar APIs may not work correctly. Try refreshing the page.'), | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	keepAppAboveKeyboard(); | ||||||
|  | 	void requestPersistentStorage(); | ||||||
|  |  | ||||||
|  | 	AppRegistry.runApplication('Joplin', { | ||||||
|  | 		rootTag: document.querySelector('#root'), | ||||||
|  | 	}); | ||||||
|  | }); | ||||||
| @@ -21,6 +21,7 @@ window.setImmediate = setImmediate; | |||||||
|  |  | ||||||
| shimInit({ | shimInit({ | ||||||
| 	nodeSqlite: sqlite3, | 	nodeSqlite: sqlite3, | ||||||
|  | 	appVersion: () => require('./package.json').version, | ||||||
| 	React, | 	React, | ||||||
| 	sharp, | 	sharp, | ||||||
| }); | }); | ||||||
| @@ -100,6 +101,10 @@ jest.doMock('react-native-fs', () => { | |||||||
| 	}; | 	}; | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | shim.fsDriver().getCacheDirectoryPath = () => { | ||||||
|  | 	return tempDirectoryPath; | ||||||
|  | }; | ||||||
|  |  | ||||||
| beforeAll(async () => { | beforeAll(async () => { | ||||||
| 	await mkdir(tempDirectoryPath); | 	await mkdir(tempDirectoryPath); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -8,6 +8,8 @@ | |||||||
|     "start": "BROWSERSLIST_IGNORE_OLD_DATA=true react-native start --reset-cache", |     "start": "BROWSERSLIST_IGNORE_OLD_DATA=true react-native start --reset-cache", | ||||||
|     "android": "react-native run-android", |     "android": "react-native run-android", | ||||||
|     "build": "NO_FLIPPER=1 gulp build", |     "build": "NO_FLIPPER=1 gulp build", | ||||||
|  |     "web": "webpack --mode production --config ./web/webpack.config.js --progress && cp -r ./web/public/* ./web/dist/", | ||||||
|  |     "serve-web": "webpack serve --mode development --static ./web/public/ --config ./web/webpack.config.js --progress", | ||||||
|     "tsc": "tsc --project tsconfig.json", |     "tsc": "tsc --project tsconfig.json", | ||||||
|     "watch": "tsc --watch --preserveWatchOutput --project tsconfig.json", |     "watch": "tsc --watch --preserveWatchOutput --project tsconfig.json", | ||||||
|     "clean": "node tools/clean.js", |     "clean": "node tools/clean.js", | ||||||
| @@ -83,13 +85,15 @@ | |||||||
|     "url": "0.11.3" |     "url": "0.11.3" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@babel/core": "7.20.2", |     "@babel/core": "7.24.7", | ||||||
|     "@babel/preset-env": "7.20.2", |     "@babel/plugin-transform-export-namespace-from": "7.24.7", | ||||||
|     "@babel/runtime": "7.20.0", |     "@babel/preset-env": "7.24.7", | ||||||
|  |     "@babel/runtime": "7.24.7", | ||||||
|     "@joplin/tools": "~3.0", |     "@joplin/tools": "~3.0", | ||||||
|     "@js-draw/material-icons": "1.20.0", |     "@js-draw/material-icons": "1.20.0", | ||||||
|     "@react-native/babel-preset": "0.74.83", |     "@react-native/babel-preset": "0.74.83", | ||||||
|     "@react-native/metro-config": "0.74.83", |     "@react-native/metro-config": "0.74.83", | ||||||
|  |     "@sqlite.org/sqlite-wasm": "3.46.0-build2", | ||||||
|     "@testing-library/jest-native": "5.4.3", |     "@testing-library/jest-native": "5.4.3", | ||||||
|     "@testing-library/react-native": "12.3.3", |     "@testing-library/react-native": "12.3.3", | ||||||
|     "@tsconfig/react-native": "2.0.2", |     "@tsconfig/react-native": "2.0.2", | ||||||
| @@ -98,10 +102,12 @@ | |||||||
|     "@types/react": "18.2.58", |     "@types/react": "18.2.58", | ||||||
|     "@types/react-native": "0.70.6", |     "@types/react-native": "0.70.6", | ||||||
|     "@types/react-redux": "7.1.33", |     "@types/react-redux": "7.1.33", | ||||||
|  |     "@types/serviceworker": "0.0.88", | ||||||
|     "@types/tar-stream": "3.1.3", |     "@types/tar-stream": "3.1.3", | ||||||
|     "babel-jest": "29.7.0", |     "babel-jest": "29.7.0", | ||||||
|     "babel-loader": "9.1.3", |     "babel-loader": "9.1.3", | ||||||
|     "babel-plugin-module-resolver": "4.1.0", |     "babel-plugin-module-resolver": "4.1.0", | ||||||
|  |     "babel-plugin-react-native-web": "0.19.12", | ||||||
|     "fs-extra": "11.2.0", |     "fs-extra": "11.2.0", | ||||||
|     "gulp": "4.0.2", |     "gulp": "4.0.2", | ||||||
|     "jest": "29.7.0", |     "jest": "29.7.0", | ||||||
| @@ -111,15 +117,21 @@ | |||||||
|     "jsdom": "23.2.0", |     "jsdom": "23.2.0", | ||||||
|     "nodemon": "3.0.3", |     "nodemon": "3.0.3", | ||||||
|     "punycode": "2.3.1", |     "punycode": "2.3.1", | ||||||
|  |     "react-dom": "18.3.1", | ||||||
|  |     "react-native-web": "0.19.12", | ||||||
|     "react-test-renderer": "18.2.0", |     "react-test-renderer": "18.2.0", | ||||||
|     "sharp": "0.33.2", |     "sharp": "0.33.2", | ||||||
|     "sqlite3": "5.1.6", |     "sqlite3": "5.1.6", | ||||||
|  |     "timers-browserify": "2.0.12", | ||||||
|     "ts-jest": "29.1.1", |     "ts-jest": "29.1.1", | ||||||
|     "ts-loader": "9.5.1", |     "ts-loader": "9.5.1", | ||||||
|     "ts-node": "10.9.2", |     "ts-node": "10.9.2", | ||||||
|     "typescript": "5.2.2", |     "typescript": "5.2.2", | ||||||
|     "uglify-js": "3.17.4", |     "uglify-js": "3.17.4", | ||||||
|     "webpack": "5.74.0" |     "url-loader": "4.1.1", | ||||||
|  |     "webpack": "5.84.0", | ||||||
|  |     "webpack-cli": "5.1.4", | ||||||
|  |     "webpack-dev-server": "5.0.4" | ||||||
|   }, |   }, | ||||||
|   "engines": { |   "engines": { | ||||||
|     "node": ">=18" |     "node": ">=18" | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								packages/app-mobile/pluginAssets/index.web.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								packages/app-mobile/pluginAssets/index.web.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | module.exports = {"hash":"d7216e17c44aad8a0b1c3cf2e01a0135","files":["highlight.js/atom-one-dark-reasonable.css","highlight.js/atom-one-light.css","katex/fonts/KaTeX_AMS-Regular.woff2","katex/fonts/KaTeX_Caligraphic-Bold.woff2","katex/fonts/KaTeX_Caligraphic-Regular.woff2","katex/fonts/KaTeX_Fraktur-Bold.woff2","katex/fonts/KaTeX_Fraktur-Regular.woff2","katex/fonts/KaTeX_Main-Bold.woff2","katex/fonts/KaTeX_Main-BoldItalic.woff2","katex/fonts/KaTeX_Main-Italic.woff2","katex/fonts/KaTeX_Main-Regular.woff2","katex/fonts/KaTeX_Math-BoldItalic.woff2","katex/fonts/KaTeX_Math-Italic.woff2","katex/fonts/KaTeX_SansSerif-Bold.woff2","katex/fonts/KaTeX_SansSerif-Italic.woff2","katex/fonts/KaTeX_SansSerif-Regular.woff2","katex/fonts/KaTeX_Script-Regular.woff2","katex/fonts/KaTeX_Size1-Regular.woff2","katex/fonts/KaTeX_Size2-Regular.woff2","katex/fonts/KaTeX_Size3-Regular.woff2","katex/fonts/KaTeX_Size4-Regular.woff2","katex/fonts/KaTeX_Typewriter-Regular.woff2","katex/katex.css","mermaid/mermaid.min.js","mermaid/mermaid_render.js"]} | ||||||
| @@ -28,8 +28,8 @@ import SyncTargetJoplinCloud from '@joplin/lib/SyncTargetJoplinCloud'; | |||||||
| import SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive'; | import SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive'; | ||||||
| import initProfile from '@joplin/lib/services/profileConfig/initProfile'; | import initProfile from '@joplin/lib/services/profileConfig/initProfile'; | ||||||
| const VersionInfo = require('react-native-version-info').default; | const VersionInfo = require('react-native-version-info').default; | ||||||
| const { Keyboard, BackHandler, Animated, View, StatusBar, Platform, Dimensions } = require('react-native'); | const { Keyboard, BackHandler, Animated, StatusBar, Platform, Dimensions } = require('react-native'); | ||||||
| import { AppState as RNAppState, EmitterSubscription, Linking, NativeEventSubscription, Appearance, AccessibilityInfo } from 'react-native'; | import { AppState as RNAppState, EmitterSubscription, View, Text, Linking, NativeEventSubscription, Appearance, AccessibilityInfo, ActivityIndicator } from 'react-native'; | ||||||
| import getResponsiveValue from './components/getResponsiveValue'; | import getResponsiveValue from './components/getResponsiveValue'; | ||||||
| import NetInfo from '@react-native-community/netinfo'; | import NetInfo from '@react-native-community/netinfo'; | ||||||
| const DropdownAlert = require('react-native-dropdownalert').default; | const DropdownAlert = require('react-native-dropdownalert').default; | ||||||
| @@ -69,7 +69,6 @@ import { MenuProvider } from 'react-native-popup-menu'; | |||||||
| import SideMenu from './components/SideMenu'; | import SideMenu from './components/SideMenu'; | ||||||
| import SideMenuContent from './components/side-menu-content'; | import SideMenuContent from './components/side-menu-content'; | ||||||
| const { SideMenuContentNote } = require('./components/side-menu-content-note.js'); | const { SideMenuContentNote } = require('./components/side-menu-content-note.js'); | ||||||
| const { DatabaseDriverReactNative } = require('./utils/database-driver-react-native'); |  | ||||||
| import { reg } from '@joplin/lib/registry'; | import { reg } from '@joplin/lib/registry'; | ||||||
| const { defaultState } = require('@joplin/lib/reducer'); | const { defaultState } = require('@joplin/lib/reducer'); | ||||||
| import FileApiDriverLocal from '@joplin/lib/file-api-driver-local'; | import FileApiDriverLocal from '@joplin/lib/file-api-driver-local'; | ||||||
| @@ -88,6 +87,8 @@ import initLib from '@joplin/lib/initLib'; | |||||||
| import { isCallbackUrl, parseCallbackUrl, CallbackUrlCommand } from '@joplin/lib/callbackUrlUtils'; | import { isCallbackUrl, parseCallbackUrl, CallbackUrlCommand } from '@joplin/lib/callbackUrlUtils'; | ||||||
| import JoplinCloudLoginScreen from './components/screens/JoplinCloudLoginScreen'; | import JoplinCloudLoginScreen from './components/screens/JoplinCloudLoginScreen'; | ||||||
|  |  | ||||||
|  | import SyncTargetNone from '@joplin/lib/SyncTargetNone'; | ||||||
|  |  | ||||||
| SyncTargetRegistry.addClass(SyncTargetNone); | SyncTargetRegistry.addClass(SyncTargetNone); | ||||||
| SyncTargetRegistry.addClass(SyncTargetOneDrive); | SyncTargetRegistry.addClass(SyncTargetOneDrive); | ||||||
| SyncTargetRegistry.addClass(SyncTargetNextcloud); | SyncTargetRegistry.addClass(SyncTargetNextcloud); | ||||||
| @@ -107,7 +108,6 @@ import setIgnoreTlsErrors from './utils/TlsUtils'; | |||||||
| import ShareService from '@joplin/lib/services/share/ShareService'; | import ShareService from '@joplin/lib/services/share/ShareService'; | ||||||
| import setupNotifications from './utils/setupNotifications'; | import setupNotifications from './utils/setupNotifications'; | ||||||
| import { loadMasterKeysFromSettings, migrateMasterPassword } from '@joplin/lib/services/e2ee/utils'; | import { loadMasterKeysFromSettings, migrateMasterPassword } from '@joplin/lib/services/e2ee/utils'; | ||||||
| import SyncTargetNone from '@joplin/lib/SyncTargetNone'; |  | ||||||
| import { setRSA } from '@joplin/lib/services/e2ee/ppk'; | import { setRSA } from '@joplin/lib/services/e2ee/ppk'; | ||||||
| import RSA from './services/e2ee/RSA.react-native'; | 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/ppkTestUtils'; | ||||||
| @@ -131,6 +131,9 @@ import PlatformImplementation from './services/plugins/PlatformImplementation'; | |||||||
| import ShareManager from './components/screens/ShareManager'; | import ShareManager from './components/screens/ShareManager'; | ||||||
| import appDefaultState, { DEFAULT_ROUTE } from './utils/appDefaultState'; | import appDefaultState, { DEFAULT_ROUTE } from './utils/appDefaultState'; | ||||||
| import { setDateFormat, setTimeFormat, setTimeLocale } from '@joplin/utils/time'; | import { setDateFormat, setTimeFormat, setTimeLocale } from '@joplin/utils/time'; | ||||||
|  | import DatabaseDriverReactNative from './utils/database-driver-react-native'; | ||||||
|  | import DialogManager from './components/DialogManager'; | ||||||
|  | import lockToSingleInstance from './utils/lockToSingleInstance'; | ||||||
| import { AppState } from './utils/types'; | import { AppState } from './utils/types'; | ||||||
| import { getDisplayParentId } from '@joplin/lib/services/trash'; | import { getDisplayParentId } from '@joplin/lib/services/trash'; | ||||||
|  |  | ||||||
| @@ -500,6 +503,8 @@ const getInitialActiveFolder = async () => { | |||||||
| 	return await Folder.load(folderId); | 	return await Folder.load(folderId); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const singleInstanceLock = lockToSingleInstance(); | ||||||
|  |  | ||||||
| // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||||
| async function initialize(dispatch: Dispatch) { | async function initialize(dispatch: Dispatch) { | ||||||
| 	shimInit(); | 	shimInit(); | ||||||
| @@ -525,6 +530,10 @@ async function initialize(dispatch: Dispatch) { | |||||||
|  |  | ||||||
| 	await shim.fsDriver().mkdir(resourceDir); | 	await shim.fsDriver().mkdir(resourceDir); | ||||||
|  |  | ||||||
|  | 	// Do as much setup as possible before checking the lock -- the lock intentionally waits for | ||||||
|  | 	// messages from other clients for several hundred ms. | ||||||
|  | 	await singleInstanceLock; | ||||||
|  |  | ||||||
| 	const logDatabase = new Database(new DatabaseDriverReactNative()); | 	const logDatabase = new Database(new DatabaseDriverReactNative()); | ||||||
| 	await logDatabase.open({ name: 'log.sqlite' }); | 	await logDatabase.open({ name: 'log.sqlite' }); | ||||||
| 	await logDatabase.exec(Logger.databaseCreateTableSql()); | 	await logDatabase.exec(Logger.databaseCreateTableSql()); | ||||||
| @@ -627,6 +636,16 @@ async function initialize(dispatch: Dispatch) { | |||||||
| 			const detectedLocale = shim.detectAndSetLocale(Setting); | 			const detectedLocale = shim.detectAndSetLocale(Setting); | ||||||
| 			reg.logger().info(`First start: detected locale as ${detectedLocale}`); | 			reg.logger().info(`First start: detected locale as ${detectedLocale}`); | ||||||
|  |  | ||||||
|  | 			if (shim.mobilePlatform() === 'web') { | ||||||
|  | 				// Web browsers generally have more limited storage than desktop and mobile apps: | ||||||
|  | 				Setting.setValue('sync.resourceDownloadMode', 'auto'); | ||||||
|  | 				// For now, geolocation is disabled by default on web until the web permissions workflow | ||||||
|  | 				// is improved. At present, trackLocation=true causes the "allow location access" prompt | ||||||
|  | 				// to appear without a clear indicator for why Joplin wants this information. | ||||||
|  | 				Setting.setValue('trackLocation', false); | ||||||
|  | 				logger.info('First start on web: Set resource download mode to auto and disabled location tracking.'); | ||||||
|  | 			} | ||||||
|  |  | ||||||
| 			Setting.skipDefaultMigrations(); | 			Setting.skipDefaultMigrations(); | ||||||
| 			Setting.setValue('firstStart', false); | 			Setting.setValue('firstStart', false); | ||||||
| 		} else { | 		} else { | ||||||
| @@ -655,7 +674,6 @@ async function initialize(dispatch: Dispatch) { | |||||||
| 			Setting.setValue('welcome.enabled', false); | 			Setting.setValue('welcome.enabled', false); | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		PluginAssetsLoader.instance().setLogger(mainLogger); |  | ||||||
| 		await PluginAssetsLoader.instance().importAssets(); | 		await PluginAssetsLoader.instance().importAssets(); | ||||||
|  |  | ||||||
| 		// eslint-disable-next-line require-atomic-updates | 		// eslint-disable-next-line require-atomic-updates | ||||||
| @@ -826,7 +844,11 @@ async function initialize(dispatch: Dispatch) { | |||||||
| 	// just print some messages in the console. | 	// just print some messages in the console. | ||||||
| 	// ---------------------------------------------------------------------------- | 	// ---------------------------------------------------------------------------- | ||||||
| 	if (Setting.value('env') === 'dev') { | 	if (Setting.value('env') === 'dev') { | ||||||
| 		await runRsaIntegrationTests(); | 		if (Platform.OS !== 'web') { | ||||||
|  | 			await runRsaIntegrationTests(); | ||||||
|  | 		} else { | ||||||
|  | 			logger.info('Skipping RSA tests -- not supported on mobile.'); | ||||||
|  | 		} | ||||||
| 		await runOnDeviceFsDriverTests(); | 		await runOnDeviceFsDriverTests(); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -930,7 +952,7 @@ class AppComponent extends React.Component { | |||||||
| 				// This will be called right after adding the event listener | 				// This will be called right after adding the event listener | ||||||
| 				// so there's no need to check netinfo on startup | 				// so there's no need to check netinfo on startup | ||||||
| 				this.unsubscribeNetInfoHandler_ = NetInfo.addEventListener(({ type, details }) => { | 				this.unsubscribeNetInfoHandler_ = NetInfo.addEventListener(({ type, details }) => { | ||||||
| 					const isMobile = details.isConnectionExpensive || type === 'cellular'; | 					const isMobile = details?.isConnectionExpensive || type === 'cellular'; | ||||||
| 					reg.setIsOnMobileData(isMobile); | 					reg.setIsOnMobileData(isMobile); | ||||||
| 					this.props.dispatch({ | 					this.props.dispatch({ | ||||||
| 						type: 'MOBILE_DATA_WARNING_UPDATE', | 						type: 'MOBILE_DATA_WARNING_UPDATE', | ||||||
| @@ -942,7 +964,16 @@ class AppComponent extends React.Component { | |||||||
| 				reg.logger().info(error); | 				reg.logger().info(error); | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			await initialize(this.props.dispatch); | 			try { | ||||||
|  | 				await initialize(this.props.dispatch); | ||||||
|  | 			} catch (error) { | ||||||
|  | 				alert(`Something went wrong while starting the application: ${error}`); | ||||||
|  | 				this.props.dispatch({ | ||||||
|  | 					type: 'APP_STATE_SET', | ||||||
|  | 					state: 'error', | ||||||
|  | 				}); | ||||||
|  | 				throw error; | ||||||
|  | 			} | ||||||
|  |  | ||||||
| 			const loadedSensorInfo = await sensorInfo(); | 			const loadedSensorInfo = await sensorInfo(); | ||||||
| 			this.setState({ sensorInfo: loadedSensorInfo }); | 			this.setState({ sensorInfo: loadedSensorInfo }); | ||||||
| @@ -1169,7 +1200,20 @@ class AppComponent extends React.Component { | |||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	public render() { | 	public render() { | ||||||
| 		if (this.props.appState !== 'ready') return null; | 		if (this.props.appState !== 'ready') { | ||||||
|  | 			if (this.props.appState === 'error') { | ||||||
|  | 				return <Text>Startup error.</Text>; | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			// Loading can take a particularly long time for the first time on web -- show progress. | ||||||
|  | 			if (Platform.OS === 'web') { | ||||||
|  | 				return <View style={{ marginLeft: 'auto', marginRight: 'auto', paddingTop: 20 }}> | ||||||
|  | 					<ActivityIndicator accessibilityLabel={_('Loading...')} /> | ||||||
|  | 				</View>; | ||||||
|  | 			} else { | ||||||
|  | 				return null; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
| 		const theme: Theme = themeStyle(this.props.themeId); | 		const theme: Theme = themeStyle(this.props.themeId); | ||||||
|  |  | ||||||
| 		let sideMenuContent: ReactNode = null; | 		let sideMenuContent: ReactNode = null; | ||||||
| @@ -1300,7 +1344,9 @@ class AppComponent extends React.Component { | |||||||
| 					}, | 					}, | ||||||
| 				}, | 				}, | ||||||
| 			}}> | 			}}> | ||||||
| 				{mainContent} | 				<DialogManager> | ||||||
|  | 					{mainContent} | ||||||
|  | 				</DialogManager> | ||||||
| 			</PaperProvider> | 			</PaperProvider> | ||||||
| 		); | 		); | ||||||
| 	} | 	} | ||||||
|   | |||||||
							
								
								
									
										33
									
								
								packages/app-mobile/services/AlarmServiceDriver.web.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								packages/app-mobile/services/AlarmServiceDriver.web.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | |||||||
|  | import Logger from '@joplin/utils/Logger'; | ||||||
|  |  | ||||||
|  | export default class AlarmServiceDriver { | ||||||
|  | 	public constructor(logger: Logger) { | ||||||
|  | 		logger.warn('WARNING: AlarmServiceDriver is not implemented on web'); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public hasPersistentNotifications() { | ||||||
|  | 		return false; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public notificationIsSet() { | ||||||
|  | 		throw new Error('Available only for non-persistent alarms'); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public setInAppNotificationHandler(_v: unknown) { | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async hasPermissions(_perm: unknown = null) { | ||||||
|  | 		return false; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async requestPermissions() { | ||||||
|  | 		return false; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async clearNotification(_id: number) { | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async scheduleNotification(_notification: Notification) { | ||||||
|  |  | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @@ -1,4 +1,6 @@ | |||||||
| import { RSA } from '@joplin/lib/services/e2ee/types'; | import { RSA } from '@joplin/lib/services/e2ee/types'; | ||||||
|  | import shim from '@joplin/lib/shim'; | ||||||
|  | import Logger from '@joplin/utils/Logger'; | ||||||
| const RnRSA = require('react-native-rsa-native').RSA; | const RnRSA = require('react-native-rsa-native').RSA; | ||||||
|  |  | ||||||
| interface RSAKeyPair { | interface RSAKeyPair { | ||||||
| @@ -7,9 +9,18 @@ interface RSAKeyPair { | |||||||
| 	keySizeBits: number; | 	keySizeBits: number; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | const logger = Logger.create('RSA'); | ||||||
|  |  | ||||||
| const rsa: RSA = { | const rsa: RSA = { | ||||||
|  |  | ||||||
| 	generateKeyPair: async (keySize: number): Promise<RSAKeyPair> => { | 	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 keys: RSAKeyPair = await RnRSA.generateKeys(keySize); | ||||||
|  |  | ||||||
| 		// Sanity check | 		// Sanity check | ||||||
|   | |||||||
| @@ -4,7 +4,6 @@ import Setting from '@joplin/lib/models/Setting'; | |||||||
| import { reg } from '@joplin/lib/registry'; | import { reg } from '@joplin/lib/registry'; | ||||||
| import BasePlatformImplementation, { Joplin } from '@joplin/lib/services/plugins/BasePlatformImplementation'; | import BasePlatformImplementation, { Joplin } from '@joplin/lib/services/plugins/BasePlatformImplementation'; | ||||||
| import { CreateFromPdfOptions, Implementation as ImagingImplementation } from '@joplin/lib/services/plugins/api/JoplinImaging'; | import { CreateFromPdfOptions, Implementation as ImagingImplementation } from '@joplin/lib/services/plugins/api/JoplinImaging'; | ||||||
| import RNVersionInfo from 'react-native-version-info'; |  | ||||||
| import { _ } from '@joplin/lib/locale'; | import { _ } from '@joplin/lib/locale'; | ||||||
| import shim from '@joplin/lib/shim'; | import shim from '@joplin/lib/shim'; | ||||||
| import Clipboard from '@react-native-clipboard/clipboard'; | import Clipboard from '@react-native-clipboard/clipboard'; | ||||||
| @@ -32,7 +31,7 @@ export default class PlatformImplementation extends BasePlatformImplementation { | |||||||
|  |  | ||||||
| 	public get versionInfo(): VersionInfo { | 	public get versionInfo(): VersionInfo { | ||||||
| 		return { | 		return { | ||||||
| 			version: RNVersionInfo.appVersion, | 			version: shim.appVersion(), | ||||||
| 			syncVersion: Setting.value('syncVersion'), | 			syncVersion: Setting.value('syncVersion'), | ||||||
| 			profileVersion: reg.db().version(), | 			profileVersion: reg.db().version(), | ||||||
| 			platform: 'mobile', | 			platform: 'mobile', | ||||||
|   | |||||||
| @@ -1,10 +1,9 @@ | |||||||
| // Helper functions to reduce the boiler plate of loading and saving profiles on | // Helper functions to reduce the boiler plate of loading and saving profiles on | ||||||
| // mobile | // mobile | ||||||
|  |  | ||||||
| const RNExitApp = require('react-native-exit-app').default; |  | ||||||
| import { Profile, ProfileConfig } from '@joplin/lib/services/profileConfig/types'; | import { Profile, ProfileConfig } from '@joplin/lib/services/profileConfig/types'; | ||||||
| import { loadProfileConfig as libLoadProfileConfig, saveProfileConfig as libSaveProfileConfig } from '@joplin/lib/services/profileConfig/index'; | import { loadProfileConfig as libLoadProfileConfig, saveProfileConfig as libSaveProfileConfig } from '@joplin/lib/services/profileConfig/index'; | ||||||
| import RNFetchBlob from 'rn-fetch-blob'; | import shim from '@joplin/lib/shim'; | ||||||
|  |  | ||||||
| // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||||
| let dispatch_: Function = null; | let dispatch_: Function = null; | ||||||
| @@ -14,7 +13,7 @@ export const setDispatch = (dispatch: Function) => { | |||||||
| }; | }; | ||||||
|  |  | ||||||
| export const getProfilesRootDir = () => { | export const getProfilesRootDir = () => { | ||||||
| 	return RNFetchBlob.fs.dirs.DocumentDir; | 	return shim.fsDriver().getAppDirectoryPath(); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export const getProfilesConfigPath = () => { | export const getProfilesConfigPath = () => { | ||||||
| @@ -55,5 +54,5 @@ export const switchProfile = async (profileId: string) => { | |||||||
|  |  | ||||||
| 	config.currentProfileId = profileId; | 	config.currentProfileId = profileId; | ||||||
| 	await saveProfileConfig(config); | 	await saveProfileConfig(config); | ||||||
| 	RNExitApp.exitApp(); | 	shim.restartApp(); | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -13,6 +13,12 @@ type TData = { | |||||||
|  |  | ||||||
| export default async (dispatch: Dispatch) => { | export default async (dispatch: Dispatch) => { | ||||||
| 	const userInfo = { url: '' }; | 	const userInfo = { url: '' }; | ||||||
|  |  | ||||||
|  | 	if (!QuickActions.setShortcutItems) { | ||||||
|  | 		logger.info('QuickActions unsupported'); | ||||||
|  | 		return null; | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	QuickActions.setShortcutItems([ | 	QuickActions.setShortcutItems([ | ||||||
| 		{ type: 'New note', title: _('New note'), icon: 'Compose', userInfo }, | 		{ type: 'New note', title: _('New note'), icon: 'Compose', userInfo }, | ||||||
| 		{ type: 'New to-do', title: _('New to-do'), icon: 'Add', userInfo }, | 		{ type: 'New to-do', title: _('New to-do'), icon: 'Add', userInfo }, | ||||||
|   | |||||||
							
								
								
									
										11
									
								
								packages/app-mobile/tools/copyAssets.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								packages/app-mobile/tools/copyAssets.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | import * as fs from 'fs-extra'; | ||||||
|  | import { dirname, join } from 'path'; | ||||||
|  |  | ||||||
|  | const copyAssets = async () => { | ||||||
|  | 	const appDir = dirname(__dirname); | ||||||
|  | 	const assetsDir = join(dirname(appDir), 'renderer', 'assets'); | ||||||
|  | 	const webDir = join(appDir, 'web', 'public'); | ||||||
|  | 	await fs.copy(assetsDir, join(webDir, 'pluginAssets')); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default copyAssets; | ||||||
| @@ -1,5 +1,6 @@ | |||||||
| const utils = require('@joplin/tools/gulp/utils'); | const utils = require('@joplin/tools/gulp/utils'); | ||||||
| const fs = require('fs-extra'); | const fs = require('fs-extra'); | ||||||
|  | const path = require('path'); | ||||||
| const md5 = require('md5'); | const md5 = require('md5'); | ||||||
|  |  | ||||||
| const rootDir = `${__dirname}/..`; | const rootDir = `${__dirname}/..`; | ||||||
| @@ -69,6 +70,10 @@ async function main() { | |||||||
| 			const hash = md5(hashes.join('')); | 			const hash = md5(hashes.join('')); | ||||||
|  |  | ||||||
| 			await fs.writeFile(`${outputDir}/index.js`, `module.exports = {\nhash:"${hash}", files: {\n${indexJs.join('\n')}\n}\n};`); | 			await fs.writeFile(`${outputDir}/index.js`, `module.exports = {\nhash:"${hash}", files: {\n${indexJs.join('\n')}\n}\n};`); | ||||||
|  | 			await fs.writeFile(`${outputDir}/index.web.js`, `module.exports = ${JSON.stringify({ | ||||||
|  | 				hash, | ||||||
|  | 				files: files.map(file => path.relative(sourceAssetDir, file)), | ||||||
|  | 			})}`); | ||||||
|  |  | ||||||
| 			return; | 			return; | ||||||
| 		} catch (error) { | 		} catch (error) { | ||||||
|   | |||||||
| @@ -1,13 +1,12 @@ | |||||||
| import Resource from '@joplin/lib/models/Resource'; | import Resource from '@joplin/lib/models/Resource'; | ||||||
| import { ResourceEntity } from '@joplin/lib/services/database/types'; | import { ResourceEntity } from '@joplin/lib/services/database/types'; | ||||||
| import shim from '@joplin/lib/shim'; | import shim from '@joplin/lib/shim'; | ||||||
| import { CachesDirectoryPath } from 'react-native-fs'; |  | ||||||
|  |  | ||||||
| // when refactoring this name, make sure to refactor the `SharePackage.java` (in android) as well | // when refactoring this name, make sure to refactor the `SharePackage.java` (in android) as well | ||||||
| const DIR_NAME = 'sharedFiles'; | const DIR_NAME = 'sharedFiles'; | ||||||
|  |  | ||||||
| const makeShareCacheDirectory = async () => { | const makeShareCacheDirectory = async () => { | ||||||
| 	const targetDir = `${CachesDirectoryPath}/${DIR_NAME}`; | 	const targetDir = `${shim.fsDriver().getCacheDirectoryPath()}/${DIR_NAME}`; | ||||||
| 	await shim.fsDriver().mkdir(targetDir); | 	await shim.fsDriver().mkdir(targetDir); | ||||||
|  |  | ||||||
| 	return targetDir; | 	return targetDir; | ||||||
| @@ -37,5 +36,5 @@ export const writeTextToCacheFile = async (text: string, fileName: string): Prom | |||||||
|  |  | ||||||
| // Clear previously shared files from cache | // Clear previously shared files from cache | ||||||
| export async function clearSharedFilesCache(): Promise<void> { | export async function clearSharedFilesCache(): Promise<void> { | ||||||
| 	return shim.fsDriver().remove(`${CachesDirectoryPath}/sharedFiles`); | 	return shim.fsDriver().remove(`${shim.fsDriver().getCacheDirectoryPath()}/sharedFiles`); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,79 +0,0 @@ | |||||||
| const SQLite = require('react-native-sqlite-storage'); |  | ||||||
|  |  | ||||||
| class DatabaseDriverReactNative { |  | ||||||
| 	constructor() { |  | ||||||
| 		this.lastInsertId_ = null; |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	open(options) { |  | ||||||
| 		// SQLite.DEBUG(true); |  | ||||||
| 		return new Promise((resolve, reject) => { |  | ||||||
| 			SQLite.openDatabase( |  | ||||||
| 				{ name: options.name }, |  | ||||||
| 				db => { |  | ||||||
| 					this.db_ = db; |  | ||||||
| 					resolve(); |  | ||||||
| 				}, |  | ||||||
| 				error => { |  | ||||||
| 					reject(error); |  | ||||||
| 				}, |  | ||||||
| 			); |  | ||||||
| 		}); |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	sqliteErrorToJsError(error) { |  | ||||||
| 		return error; |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	selectOne(sql, params = null) { |  | ||||||
| 		return new Promise((resolve, reject) => { |  | ||||||
| 			this.db_.executeSql( |  | ||||||
| 				sql, |  | ||||||
| 				params, |  | ||||||
| 				r => { |  | ||||||
| 					resolve(r.rows.length ? r.rows.item(0) : null); |  | ||||||
| 				}, |  | ||||||
| 				error => { |  | ||||||
| 					reject(error); |  | ||||||
| 				}, |  | ||||||
| 			); |  | ||||||
| 		}); |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	selectAll(sql, params = null) { |  | ||||||
| 		// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied |  | ||||||
| 		return this.exec(sql, params).then(r => { |  | ||||||
| 			const output = []; |  | ||||||
| 			for (let i = 0; i < r.rows.length; i++) { |  | ||||||
| 				output.push(r.rows.item(i)); |  | ||||||
| 			} |  | ||||||
| 			return output; |  | ||||||
| 		}); |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	loadExtension(path) { |  | ||||||
| 		throw new Error(`No extension support for ${path} in react-native-sqlite-storage`); |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	exec(sql, params = null) { |  | ||||||
| 		return new Promise((resolve, reject) => { |  | ||||||
| 			this.db_.executeSql( |  | ||||||
| 				sql, |  | ||||||
| 				params, |  | ||||||
| 				r => { |  | ||||||
| 					if ('insertId' in r) this.lastInsertId_ = r.insertId; |  | ||||||
| 					resolve(r); |  | ||||||
| 				}, |  | ||||||
| 				error => { |  | ||||||
| 					reject(error); |  | ||||||
| 				}, |  | ||||||
| 			); |  | ||||||
| 		}); |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	lastInsertId() { |  | ||||||
| 		return this.lastInsertId_; |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  |  | ||||||
| module.exports = { DatabaseDriverReactNative }; |  | ||||||
							
								
								
									
										84
									
								
								packages/app-mobile/utils/database-driver-react-native.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								packages/app-mobile/utils/database-driver-react-native.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | |||||||
|  | const SQLite = require('react-native-sqlite-storage'); | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export default class DatabaseDriverReactNative { | ||||||
|  | 	private lastInsertId_: string; | ||||||
|  | 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||||
|  | 	private db_: any; | ||||||
|  | 	public constructor() { | ||||||
|  | 		this.lastInsertId_ = null; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public open(options: { name: string }) { | ||||||
|  | 		// SQLite.DEBUG(true); | ||||||
|  | 		return new Promise<void>((resolve, reject) => { | ||||||
|  | 			SQLite.openDatabase( | ||||||
|  | 				{ name: options.name }, | ||||||
|  | 				// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||||
|  | 				(db: any) => { | ||||||
|  | 					this.db_ = db; | ||||||
|  | 					resolve(); | ||||||
|  | 				}, | ||||||
|  | 				(error: Error) => { | ||||||
|  | 					reject(error); | ||||||
|  | 				}, | ||||||
|  | 			); | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public sqliteErrorToJsError(error: Error) { | ||||||
|  | 		return error; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public selectOne(sql: string, params: unknown = null) { | ||||||
|  | 		return new Promise<unknown>((resolve, reject) => { | ||||||
|  | 			this.db_.executeSql( | ||||||
|  | 				sql, | ||||||
|  | 				params, | ||||||
|  | 				// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||||
|  | 				(r: any) => { | ||||||
|  | 					resolve(r.rows.length ? r.rows.item(0) : null); | ||||||
|  | 				}, | ||||||
|  | 				(error: Error) => { | ||||||
|  | 					reject(error); | ||||||
|  | 				}, | ||||||
|  | 			); | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public selectAll(sql: string, params: unknown = null) { | ||||||
|  | 		// eslint-disable-next-line promise/prefer-await-to-then -- Old code before rule was applied | ||||||
|  | 		return this.exec(sql, params).then(r => { | ||||||
|  | 			const output = []; | ||||||
|  | 			for (let i = 0; i < r.rows.length; i++) { | ||||||
|  | 				output.push(r.rows.item(i)); | ||||||
|  | 			} | ||||||
|  | 			return output; | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public loadExtension(path: string) { | ||||||
|  | 		throw new Error(`No extension support for ${path} in react-native-sqlite-storage`); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public exec(sql: string, params: unknown = null) { | ||||||
|  | 		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code from before rule was applied | ||||||
|  | 		return new Promise<any>((resolve, reject) => { | ||||||
|  | 			this.db_.executeSql( | ||||||
|  | 				sql, | ||||||
|  | 				params, | ||||||
|  | 				(r: { insertId: string }) => { | ||||||
|  | 					if ('insertId' in r) this.lastInsertId_ = r.insertId; | ||||||
|  | 					resolve(r); | ||||||
|  | 				}, | ||||||
|  | 				(error: Error) => { | ||||||
|  | 					reject(error); | ||||||
|  | 				}, | ||||||
|  | 			); | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public lastInsertId() { | ||||||
|  | 		return this.lastInsertId_; | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @@ -0,0 +1,93 @@ | |||||||
|  | const { sqlite3Worker1Promiser } = require('@sqlite.org/sqlite-wasm'); | ||||||
|  | import { safeFilename } from '@joplin/utils/path'; | ||||||
|  |  | ||||||
|  | type DbPromiser = (command: string, options: Record<string, unknown>)=> Promise<unknown>; | ||||||
|  | type DbId = unknown; | ||||||
|  | type RowResult = { rowNumber: number|null; row: unknown }; | ||||||
|  |  | ||||||
|  | export default class DatabaseDriverReactNative { | ||||||
|  | 	private lastInsertId_: string; | ||||||
|  | 	private db_: DbPromiser; | ||||||
|  | 	private dbId_: DbId; | ||||||
|  | 	public constructor() { | ||||||
|  | 		this.lastInsertId_ = null; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async open(options: { name: string }) { | ||||||
|  | 		const db = await new Promise<DbPromiser>((resolve) => { | ||||||
|  | 			const db = sqlite3Worker1Promiser({ | ||||||
|  | 				onready: () => resolve(db), | ||||||
|  | 			}); | ||||||
|  | 		}); | ||||||
|  | 		const filename = `file:${safeFilename(options.name)}.sqlite3?vfs=opfs`; | ||||||
|  |  | ||||||
|  | 		type OpenResult = { dbId: number }; | ||||||
|  | 		const { dbId } = await db('open', { filename }) as OpenResult; | ||||||
|  | 		this.dbId_ = dbId; | ||||||
|  | 		this.db_ = db; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public sqliteErrorToJsError(error: Error) { | ||||||
|  | 		return error; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public selectOne(sql: string, params: string[] = []) { | ||||||
|  | 		// eslint-disable-next-line no-async-promise-executor -- Wraps an API that mixes callbacks and promises. | ||||||
|  | 		return new Promise<unknown>(async (resolve, reject) => { | ||||||
|  | 			let resolved = false; | ||||||
|  |  | ||||||
|  | 			await this.db_('exec', { | ||||||
|  | 				dbId: this.dbId_, | ||||||
|  | 				sql, | ||||||
|  | 				bind: params, | ||||||
|  | 				rowMode: 'object', | ||||||
|  | 				callback: ((result: RowResult) => { | ||||||
|  | 					if (result.rowNumber !== 1) return; | ||||||
|  | 					resolved = true; | ||||||
|  | 					resolve(result.row); | ||||||
|  | 				}), | ||||||
|  | 			}).catch(reject); | ||||||
|  |  | ||||||
|  | 			if (!resolved) { | ||||||
|  | 				resolve(null); | ||||||
|  | 			} | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async selectAll(sql: string, params: string[] = null) { | ||||||
|  | 		const results: unknown[] = []; | ||||||
|  | 		await this.db_('exec', { | ||||||
|  | 			dbId: this.dbId_, | ||||||
|  | 			sql, | ||||||
|  | 			bind: params, | ||||||
|  | 			rowMode: 'object', | ||||||
|  | 			callback: ((rowResult: RowResult) => { | ||||||
|  | 				if (rowResult.rowNumber) { | ||||||
|  | 					while (results.length < rowResult.rowNumber) { | ||||||
|  | 						results.push(null); | ||||||
|  | 					} | ||||||
|  | 					results[rowResult.rowNumber - 1] = rowResult.row; | ||||||
|  | 				} | ||||||
|  | 			}), | ||||||
|  | 		}); | ||||||
|  |  | ||||||
|  | 		return results; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public loadExtension(path: string) { | ||||||
|  | 		throw new Error(`No extension support for ${path} in sqlite wasm`); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async exec(sql: string, params: string[]|null = null) { | ||||||
|  | 		const result = await this.db_('exec', { | ||||||
|  | 			dbId: this.dbId_, | ||||||
|  | 			sql, | ||||||
|  | 			bind: params, | ||||||
|  | 		}); | ||||||
|  | 		return result; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public lastInsertId() { | ||||||
|  | 		return this.lastInsertId_; | ||||||
|  | 	} | ||||||
|  | } | ||||||
| @@ -351,6 +351,14 @@ export default class FsDriverRN extends FsDriverBase { | |||||||
| 		return directory; | 		return directory; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	public getCacheDirectoryPath() { | ||||||
|  | 		return RNFS.CachesDirectoryPath; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public getAppDirectoryPath() { | ||||||
|  | 		return RNFetchBlob.fs.dirs.DocumentDir; | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	public isUsingAndroidSAF() { | 	public isUsingAndroidSAF() { | ||||||
| 		return Platform.OS === 'android' && Platform.Version > 28; | 		return Platform.OS === 'android' && Platform.Version > 28; | ||||||
| 	} | 	} | ||||||
|   | |||||||
							
								
								
									
										231
									
								
								packages/app-mobile/utils/fs-driver/fs-driver-rn.web.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										231
									
								
								packages/app-mobile/utils/fs-driver/fs-driver-rn.web.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,231 @@ | |||||||
|  | import { resolve } from 'path'; | ||||||
|  | import FsDriverBase, { ReadDirStatsOptions, RemoveOptions, Stat } from '@joplin/lib/fs-driver-base'; | ||||||
|  | import tarExtract, { TarExtractOptions } from './tarExtract'; | ||||||
|  | import tarCreate, { TarCreateOptions } from './tarCreate'; | ||||||
|  | import { Buffer } from 'buffer'; | ||||||
|  | import Logger, { LogLevel, TargetType } from '@joplin/utils/Logger'; | ||||||
|  | import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger'; | ||||||
|  | import type { AccessMode, TransferableStat, WorkerApi } from './fs-driver-rn.web.worker'; | ||||||
|  | import WorkerMessenger from '@joplin/lib/utils/ipc/WorkerMessenger'; | ||||||
|  | import JoplinError from '@joplin/lib/JoplinError'; | ||||||
|  |  | ||||||
|  | type FileHandle = { | ||||||
|  | 	reader: ReadableStreamDefaultReader<Uint8Array>; | ||||||
|  | 	buffered: Buffer; | ||||||
|  | 	done: boolean; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | declare global { | ||||||
|  | 	interface FileSystemDirectoryHandle { | ||||||
|  | 		entries(): AsyncIterable<[string, FileSystemFileHandle|FileSystemDirectoryHandle]>; | ||||||
|  | 		keys(): AsyncIterable<string>; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const logger = new Logger(); | ||||||
|  | logger.addTarget(TargetType.Console); | ||||||
|  | logger.setLevel(LogLevel.Warn); | ||||||
|  |  | ||||||
|  | const transferableStatToStat = (stat: TransferableStat): Stat => { | ||||||
|  | 	return { | ||||||
|  | 		mtime: new Date(stat.mtime), | ||||||
|  | 		birthtime: new Date(stat.birthtime), | ||||||
|  | 		size: stat.size, | ||||||
|  | 		path: stat.path, | ||||||
|  | 		isDirectory: () => stat.isDirectory, | ||||||
|  | 	}; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | interface LocalWorkerApi { } | ||||||
|  | type MessengerType = RemoteMessenger<LocalWorkerApi, WorkerApi>; | ||||||
|  | let messenger: MessengerType|null = null; | ||||||
|  |  | ||||||
|  | // Ensures that all instances of FsDriverWeb share the same worker. This is important | ||||||
|  | // for virtual files to be correctly handled. | ||||||
|  | const getWorkerMessenger = () => { | ||||||
|  | 	if (messenger) { | ||||||
|  | 		return messenger; | ||||||
|  | 	} | ||||||
|  | 	const worker = new Worker( | ||||||
|  | 		// Webpack has special syntax for creating a worker. It requires use | ||||||
|  | 		// of import.meta.url, which is prohibited by TypeScript in CommonJS | ||||||
|  | 		// modules. | ||||||
|  | 		// | ||||||
|  | 		// See also https://github.com/webpack/webpack/discussions/13655#discussioncomment-8382152 | ||||||
|  | 		// | ||||||
|  | 		// TODO: Remove this after migrating to ESM. | ||||||
|  | 		// | ||||||
|  | 		// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- Required for webpack build (see above) | ||||||
|  | 		// @ts-ignore | ||||||
|  | 		new URL('./fs-driver-rn.web.worker.ts', import.meta.url), | ||||||
|  | 	); | ||||||
|  | 	messenger = new WorkerMessenger('fs-worker', worker, {}); | ||||||
|  | 	return messenger; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default class FsDriverWeb extends FsDriverBase { | ||||||
|  | 	private messenger_: RemoteMessenger<LocalWorkerApi, WorkerApi>; | ||||||
|  |  | ||||||
|  | 	public constructor() { | ||||||
|  | 		super(); | ||||||
|  |  | ||||||
|  | 		this.messenger_ = getWorkerMessenger(); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async writeFile( | ||||||
|  | 		path: string, | ||||||
|  | 		data: string|ArrayBuffer, | ||||||
|  | 		encoding: BufferEncoding|'Buffer' = 'base64', | ||||||
|  | 		options?: FileSystemCreateWritableOptions, | ||||||
|  | 	) { | ||||||
|  | 		await this.messenger_.remoteApi.writeFile(path, data, encoding, options); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async appendFile(path: string, content: string, encoding?: BufferEncoding) { | ||||||
|  | 		return this.writeFile(path, content, encoding, { keepExistingData: true }); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async remove(path: string, { recursive = true }: RemoveOptions = {}) { | ||||||
|  | 		await this.messenger_.remoteApi.remove(path, { recursive }); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async unlink(path: string) { | ||||||
|  | 		return this.messenger_.remoteApi.unlink(path); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async fileAtPath(path: string) { | ||||||
|  | 		try { | ||||||
|  | 			return await this.messenger_.remoteApi.fileAtPath(path); | ||||||
|  | 		} catch (error) { | ||||||
|  | 			if (!await this.exists(path)) { | ||||||
|  | 				throw new JoplinError(`fileAtPath path doesn't exist: ${error}, stack: ${error.stack}`, 'ENOENT'); | ||||||
|  | 			} | ||||||
|  | 			throw error; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async readFile(path: string, encoding: BufferEncoding|'Buffer' = 'utf-8') { | ||||||
|  | 		logger.debug('readFile', path); | ||||||
|  | 		const file = await this.fileAtPath(path); | ||||||
|  |  | ||||||
|  | 		if (encoding === 'utf-8' || encoding === 'utf8') { | ||||||
|  | 			return await file.text(); | ||||||
|  | 		} else if (encoding === 'Buffer') { | ||||||
|  | 			return Buffer.from(await file.arrayBuffer()); | ||||||
|  | 		} else { | ||||||
|  | 			const buffer = Buffer.from(await file.arrayBuffer()); | ||||||
|  | 			return buffer.toString(encoding); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async open(path: string, _mode = 'r'): Promise<FileHandle> { | ||||||
|  | 		const file = await this.fileAtPath(path); | ||||||
|  | 		return { | ||||||
|  | 			// TODO: Extra casting required by NodeJS types conflicting with DOM types. | ||||||
|  | 			reader: (file.stream() as unknown as ReadableStream).getReader(), | ||||||
|  | 			buffered: Buffer.from([]), | ||||||
|  | 			done: false, | ||||||
|  | 		}; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async readFileChunkAsBuffer(handle: FileHandle, length: number): Promise<Buffer> { | ||||||
|  | 		let read: Buffer = handle.buffered; | ||||||
|  |  | ||||||
|  | 		while (read.byteLength < length && !handle.done) { | ||||||
|  | 			const { done, value } = await handle.reader.read(); | ||||||
|  | 			handle.done = done; | ||||||
|  | 			if (value) { | ||||||
|  | 				if (read.byteLength > 0) { | ||||||
|  | 					read = Buffer.concat([read, value], read.byteLength + value.byteLength); | ||||||
|  | 				} else { | ||||||
|  | 					read = Buffer.from(value); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		const result = read.subarray(0, length); | ||||||
|  | 		handle.buffered = read.subarray(length, read.length); | ||||||
|  | 		if (result.length === 0) { | ||||||
|  | 			return null; | ||||||
|  | 		} else { | ||||||
|  | 			return result; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async readFileChunk(handle: FileHandle, length: number, encoding: BufferEncoding = 'base64') { | ||||||
|  | 		return (await this.readFileChunkAsBuffer(handle, length))?.toString(encoding) ?? null; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async close(handle: FileHandle) { | ||||||
|  | 		handle.reader?.releaseLock(); | ||||||
|  | 		handle.reader = null; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async mkdir(path: string) { | ||||||
|  | 		logger.debug('mkdir', path); | ||||||
|  | 		await this.messenger_.remoteApi.mkdir(path); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async copy(from: string, to: string) { | ||||||
|  | 		await this.messenger_.remoteApi.copy(from, to); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async stat(path: string): Promise<Stat|null> { | ||||||
|  | 		const stat = await this.messenger_.remoteApi.stat(path); | ||||||
|  | 		if (!stat) return null; | ||||||
|  | 		return transferableStatToStat(stat); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async readDirStats(path: string, options: ReadDirStatsOptions = { recursive: false }): Promise<Stat[]> { | ||||||
|  | 		const stats = (await this.messenger_.remoteApi.readDirStats(path, options))?.map(transferableStatToStat); | ||||||
|  | 		if (!stats) { | ||||||
|  | 			throw new JoplinError(`Path ${path} does not exist (readDirStats)`, 'ENOENT'); | ||||||
|  | 		} | ||||||
|  | 		return stats; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async exists(path: string) { | ||||||
|  | 		return await this.messenger_.remoteApi.exists(path); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public resolve(...paths: string[]): string { | ||||||
|  | 		return resolve(...paths); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async md5File(path: string): Promise<string> { | ||||||
|  | 		return await this.messenger_.remoteApi.md5File(path); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async tarExtract(options: TarExtractOptions) { | ||||||
|  | 		await tarExtract({ | ||||||
|  | 			cwd: '/cache/', | ||||||
|  | 			...options, | ||||||
|  | 		}); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override async tarCreate(options: TarCreateOptions, filePaths: string[]) { | ||||||
|  | 		await tarCreate({ | ||||||
|  | 			cwd: '/cache/', | ||||||
|  | 			...options, | ||||||
|  | 		}, filePaths); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override getCacheDirectoryPath(): string { | ||||||
|  | 		return '/cache/'; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public override getAppDirectoryPath(): string { | ||||||
|  | 		return '/app/'; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async createReadOnlyVirtualFile(path: string, content: File) { | ||||||
|  | 		return this.messenger_.remoteApi.createReadOnlyVirtualFile(path, content); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async mountExternalDirectory(handle: FileSystemDirectoryHandle, id: string, mode: AccessMode) { | ||||||
|  | 		const externalUri = await this.messenger_.remoteApi.mountExternalDirectory(handle, id, mode); | ||||||
|  | 		logger.info('Mounted handle with ID', id, 'at', externalUri); | ||||||
|  | 		return externalUri; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
							
								
								
									
										518
									
								
								packages/app-mobile/utils/fs-driver/fs-driver-rn.web.worker.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										518
									
								
								packages/app-mobile/utils/fs-driver/fs-driver-rn.web.worker.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,518 @@ | |||||||
|  | import type { ReadDirStatsOptions, RemoveOptions } from '@joplin/lib/fs-driver-base'; | ||||||
|  | import WorkerToWindowMessenger from '@joplin/lib/utils/ipc/WorkerToWindowMessenger'; | ||||||
|  | import Logger, { LogLevel, TargetType } from '@joplin/utils/Logger'; | ||||||
|  | import { resolve, dirname, basename, normalize, join } from 'path'; | ||||||
|  | import { Buffer } from 'buffer'; | ||||||
|  | const md5 = require('md5'); | ||||||
|  |  | ||||||
|  | const removeReservedWords = (fileName: string) => { | ||||||
|  | 	return fileName.replace(/(tmp)$/g, '_$1'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const restoreReservedWords = (fileName: string) => { | ||||||
|  | 	return fileName.replace(/_tmp$/g, 'tmp'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type AccessMode = 'read'|'readwrite'; | ||||||
|  |  | ||||||
|  | declare global { | ||||||
|  | 	interface FileSystemSyncAccessHandle { | ||||||
|  | 		close(): void; | ||||||
|  | 		truncate(to: number): void; | ||||||
|  | 		write(buffer: ArrayBuffer|ArrayBufferView, options?: { at: number }): void; | ||||||
|  | 		read(buffer: ArrayBuffer|ArrayBufferView, options?: { at: number }): number; | ||||||
|  | 		getSize(): number; | ||||||
|  | 		flush(): void; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	interface FileSystemHandle { | ||||||
|  | 		requestPermission(permission: { mode: AccessMode }): Promise<'granted'|string>; | ||||||
|  | 		queryPermission(permission: { mode: AccessMode }): Promise<'granted'|string>; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	interface FileSystemFileHandle { | ||||||
|  | 		createSyncAccessHandle(): Promise<FileSystemSyncAccessHandle>; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	interface FileSystemDirectoryHandle { | ||||||
|  | 		entries(): AsyncIterable<[string, FileSystemFileHandle|FileSystemDirectoryHandle]>; | ||||||
|  | 		keys(): AsyncIterable<string>; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type WriteFileOptions = { keepExistingData?: boolean }; | ||||||
|  |  | ||||||
|  | const logger = new Logger(); | ||||||
|  | logger.addTarget(TargetType.Console); | ||||||
|  | logger.setLevel(LogLevel.Info); | ||||||
|  |  | ||||||
|  | export interface TransferableStat { | ||||||
|  | 	birthtime: number; | ||||||
|  | 	mtime: number; | ||||||
|  | 	path: string; | ||||||
|  | 	size: number; | ||||||
|  | 	isDirectory: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const isNotFoundError = (error: DOMException) => error.name === 'NotFoundError'; | ||||||
|  | const isTypeMismatchError = (error: DOMException) => error.name === 'TypeMismatchError'; | ||||||
|  | const externalDirectoryPrefix = '/external/'; | ||||||
|  |  | ||||||
|  | type AccessHandleDatabaseControl = { | ||||||
|  | 	clearExternalHandle(id: string): Promise<void>; | ||||||
|  | 	addExternalHandle(path: string, id: string, handle: FileSystemDirectoryHandle|FileSystemFileHandle, mode: AccessMode): Promise<void>; | ||||||
|  | 	queryExternalHandle(path: string): Promise<[FileSystemDirectoryHandle|FileSystemFileHandle, AccessMode]|null>; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | // Allows saving and restoring file system access handles. These handles are browser-serializable, so can | ||||||
|  | // be written to indexedDB. Here, indexedDB is used, rather than localStorage or SQLite because: | ||||||
|  | // - localStorage only accepts string values (see https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem) | ||||||
|  | // - SQLite stores objects in a custom database, and so almost certainly can't store file system handles. | ||||||
|  | const createAccessHandleDatabase = async (): Promise<AccessHandleDatabaseControl> => { | ||||||
|  | 	const db = await new Promise<IDBDatabase>((resolve, reject) => { | ||||||
|  | 		const request = indexedDB.open('fs-storage', 1); | ||||||
|  | 		request.onsuccess = () => { | ||||||
|  | 			resolve(request.result as IDBDatabase); | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		request.onerror = (event) => { | ||||||
|  | 			reject(new Error(`Failed to open database: ${event}`)); | ||||||
|  | 		}; | ||||||
|  |  | ||||||
|  | 		request.onupgradeneeded = (event) => { | ||||||
|  | 			if (!('result' in event.target)) { | ||||||
|  | 				reject(new Error('Invalid upgrade event type')); | ||||||
|  | 				return; | ||||||
|  | 			} | ||||||
|  | 			const db = event.target.result as IDBDatabase; | ||||||
|  | 			const store = db.createObjectStore('external-handles', { keyPath: 'id' }); | ||||||
|  | 			store.createIndex('id', 'id', { unique: true }); | ||||||
|  | 			store.createIndex('path', 'path'); | ||||||
|  | 		}; | ||||||
|  | 	}); | ||||||
|  |  | ||||||
|  | 	const toUniquePath = (path: string) => { | ||||||
|  | 		// normalize can leave the trailing / | ||||||
|  | 		return normalize(path).replace(/[/]$/, ''); | ||||||
|  | 	}; | ||||||
|  |  | ||||||
|  | 	return { | ||||||
|  | 		clearExternalHandle(id: string) { | ||||||
|  | 			return new Promise<void>((resolve, reject) => { | ||||||
|  | 				const request = db.transaction(['external-handles'], 'readwrite') | ||||||
|  | 					.objectStore('external-handles') | ||||||
|  | 					.delete(`id:${id}`); | ||||||
|  |  | ||||||
|  | 				request.onsuccess = () => resolve(); | ||||||
|  | 				request.onerror = (event) => reject(new Error(`Transaction failed: ${event}`)); | ||||||
|  | 			}); | ||||||
|  | 		}, | ||||||
|  | 		addExternalHandle(path: string, id: string, handle: FileSystemDirectoryHandle|FileSystemFileHandle, mode: AccessMode) { | ||||||
|  | 			path = toUniquePath(path); | ||||||
|  |  | ||||||
|  | 			return new Promise<void>((resolve, reject) => { | ||||||
|  | 				const request = db.transaction(['external-handles'], 'readwrite') | ||||||
|  | 					.objectStore('external-handles') | ||||||
|  | 					.put({ path, id: `id:${id}`, handle, mode }); | ||||||
|  |  | ||||||
|  | 				request.onsuccess = () => resolve(); | ||||||
|  | 				request.onerror = (event) => reject(new Error(`Transaction failed: ${event}`)); | ||||||
|  | 			}); | ||||||
|  | 		}, | ||||||
|  | 		queryExternalHandle(path: string) { | ||||||
|  | 			path = toUniquePath(path); | ||||||
|  |  | ||||||
|  | 			return new Promise<[FileSystemDirectoryHandle|FileSystemFileHandle, AccessMode]|null>((resolve, reject) => { | ||||||
|  | 				const request = db.transaction(['external-handles'], 'readonly') | ||||||
|  | 					.objectStore('external-handles') | ||||||
|  | 					.index('path') | ||||||
|  | 					.get(path); | ||||||
|  |  | ||||||
|  | 				request.onsuccess = () => { | ||||||
|  | 					const handle = request.result?.handle; | ||||||
|  | 					if (request.result && request.result.path !== path) { | ||||||
|  | 						throw new Error(`Path mismatch when querying external directory handle: ${JSON.stringify(path)} was ${JSON.stringify(request.result.path)}`); | ||||||
|  | 					} | ||||||
|  | 					resolve(handle ? [handle, request.result?.mode ?? 'readwrite'] : null); | ||||||
|  | 				}; | ||||||
|  | 				request.onerror = (event) => reject(new Error(`Transaction failed: ${event}`)); | ||||||
|  | 			}); | ||||||
|  | 		}, | ||||||
|  | 	}; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export class WorkerApi { | ||||||
|  | 	private fsRoot_: FileSystemDirectoryHandle; | ||||||
|  | 	private accessHandleDatabase_: AccessHandleDatabaseControl; | ||||||
|  |  | ||||||
|  | 	private directoryHandleCache_: Map<string, FileSystemDirectoryHandle> = new Map(); | ||||||
|  | 	private virtualFiles_: Map<string, File> = new Map(); | ||||||
|  | 	private externalHandles_: Map<string, FileSystemFileHandle|FileSystemDirectoryHandle> = new Map(); | ||||||
|  | 	private initPromise_: Promise<void>; | ||||||
|  |  | ||||||
|  | 	public constructor() { | ||||||
|  | 		this.initPromise_ = (async () => { | ||||||
|  | 			let lastError: Error|null = null; | ||||||
|  | 			for (let retry = 0; retry < 2; retry++) { | ||||||
|  | 				try { | ||||||
|  | 					this.fsRoot_ ??= await (await navigator.storage.getDirectory()).getDirectoryHandle('joplin-web', { create: true }); | ||||||
|  | 					this.accessHandleDatabase_ ??= await createAccessHandleDatabase(); | ||||||
|  | 					lastError = null; | ||||||
|  | 					break; | ||||||
|  | 				} catch (error) { | ||||||
|  | 					logger.warn('Failed to create fs-driver:', error, `(retry: ${retry})`); | ||||||
|  | 					lastError = error; | ||||||
|  |  | ||||||
|  | 					await new Promise<void>(resolve => setTimeout(() => resolve(), 1000)); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			if (lastError) { | ||||||
|  | 				throw lastError; | ||||||
|  | 			} | ||||||
|  | 		})(); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	private async getExternalHandle_(path: string) { | ||||||
|  | 		path = normalize(path); | ||||||
|  |  | ||||||
|  | 		if (!path.startsWith(externalDirectoryPrefix)) { | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if (this.externalHandles_.has(path)) { | ||||||
|  | 			return this.externalHandles_.get(path); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		const saved = await this.accessHandleDatabase_.queryExternalHandle(path); | ||||||
|  | 		if (!saved) { | ||||||
|  | 			logger.debug('External lookup failed for', path); | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  | 		const [handle, mode] = saved; | ||||||
|  |  | ||||||
|  | 		// At present, not all browsers support .queryPermission and .requestPermission on | ||||||
|  | 		// saved file handles. | ||||||
|  | 		if (!('queryPermission' in handle)) { | ||||||
|  | 			logger.warn('Browser does not support .queryPermission. Loading path: ', path); | ||||||
|  | 			return null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		const permission = { mode }; | ||||||
|  | 		if (await handle.queryPermission(permission) !== 'granted' && await handle.requestPermission(permission) !== 'granted') { | ||||||
|  | 			throw new Error('Missing read-write access. It might be necessary to share the folder with the application again.'); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		this.externalHandles_.set(path, handle); | ||||||
|  | 		return handle; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	private async pathToDirectoryHandle_(path: string, create = false): Promise<FileSystemDirectoryHandle|null> { | ||||||
|  | 		await this.initPromise_; | ||||||
|  | 		path = resolve('/', path); | ||||||
|  |  | ||||||
|  | 		if (path === '/') { | ||||||
|  | 			return this.fsRoot_; | ||||||
|  | 		} else if (`${path}/`.startsWith(externalDirectoryPrefix)) { | ||||||
|  | 			if (path === externalDirectoryPrefix || `${path}/` === externalDirectoryPrefix) { | ||||||
|  | 				// /external/ is virtual, it doesn't exist. | ||||||
|  | 				return null; | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			const handle = await this.getExternalHandle_(path); | ||||||
|  | 			if (handle?.kind === 'directory') { | ||||||
|  | 				return handle; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		if (this.directoryHandleCache_.has(path)) { | ||||||
|  | 			logger.debug('pathToDirectoryHandle_ from cache for', path); | ||||||
|  | 			return this.directoryHandleCache_.get(path); | ||||||
|  | 		} | ||||||
|  | 		logger.debug('pathToDirectoryHandle_', 'path:', path, 'create:', create); | ||||||
|  |  | ||||||
|  | 		const parentDirs = dirname(path); | ||||||
|  | 		const parent = await this.pathToDirectoryHandle_(parentDirs, create); | ||||||
|  | 		const folderName = removeReservedWords(basename(path)); | ||||||
|  |  | ||||||
|  | 		let handle: FileSystemDirectoryHandle; | ||||||
|  | 		try { | ||||||
|  | 			handle = await parent.getDirectoryHandle(folderName, { create }); | ||||||
|  | 			this.directoryHandleCache_.set(path, handle); | ||||||
|  | 		} catch (error) { | ||||||
|  | 			// TODO: Handle this better | ||||||
|  | 			logger.warn('Error getting directory handle', error, 'for', path, 'create:', create); | ||||||
|  | 			handle = null; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		return handle; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	private async pathToFileHandle_(path: string, create = false): Promise<FileSystemFileHandle> { | ||||||
|  | 		await this.initPromise_; | ||||||
|  | 		path = resolve('/', path); | ||||||
|  |  | ||||||
|  | 		if (this.externalHandles_.has(path)) { | ||||||
|  | 			const handle = await this.externalHandles_.get(path); | ||||||
|  | 			if (handle.kind !== 'file') { | ||||||
|  | 				throw new Error(`Not a file: ${path}`); | ||||||
|  | 			} | ||||||
|  | 			return handle; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		logger.debug('pathToFileHandle_', 'path:', path, 'create:', create); | ||||||
|  | 		const parent = await this.pathToDirectoryHandle_(dirname(path)); | ||||||
|  | 		if (!parent) { | ||||||
|  | 			throw new Error(`Can't get file handle for path ${path} -- parent doesn't exist (create: ${create}).`); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		try { | ||||||
|  | 			return parent.getFileHandle(removeReservedWords(basename(path)), { create }); | ||||||
|  | 		} catch (error) { | ||||||
|  | 			if (create) { | ||||||
|  | 				throw new Error(`${error} while getting file at path ${path}.`); | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			if (isNotFoundError(error)) { | ||||||
|  | 				return null; | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			logger.warn(error, 'getting file handle at path', path, create); | ||||||
|  | 			throw error; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async writeFile( | ||||||
|  | 		path: string, | ||||||
|  | 		data: string|ArrayBuffer, | ||||||
|  | 		encoding: BufferEncoding|'Buffer' = 'base64', | ||||||
|  | 		options?: WriteFileOptions, | ||||||
|  | 	) { | ||||||
|  | 		logger.debug('writeFile', path); | ||||||
|  | 		const handle = await this.pathToFileHandle_(path, true); | ||||||
|  | 		let write, close; | ||||||
|  |  | ||||||
|  | 		try { | ||||||
|  | 			try { | ||||||
|  | 				const writer = await handle.createSyncAccessHandle(); | ||||||
|  |  | ||||||
|  | 				let at = 0; | ||||||
|  | 				if (!options?.keepExistingData) { | ||||||
|  | 					writer.truncate(0); | ||||||
|  | 				} else { | ||||||
|  | 					at = writer.getSize(); | ||||||
|  | 				} | ||||||
|  |  | ||||||
|  | 				write = (data: ArrayBufferLike) => writer.write(data, { at }); | ||||||
|  | 				close = () => writer.close(); | ||||||
|  | 			} catch (error) { | ||||||
|  | 				// In some cases, createSyncAccessHandle isn't available. In other cases, | ||||||
|  | 				// createWritable isn't available. | ||||||
|  |  | ||||||
|  | 				logger.warn('Failed to createSyncAccessHandle', error); | ||||||
|  | 				const writer = await handle.createWritable({ keepExistingData: options?.keepExistingData }); | ||||||
|  | 				write = (data: ArrayBufferLike) => writer.write(data); | ||||||
|  | 				close = () => writer.close(); | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			if (encoding === 'Buffer') { | ||||||
|  | 				await write(data as ArrayBuffer); | ||||||
|  | 			} else if (data instanceof ArrayBuffer) { | ||||||
|  | 				throw new Error('Cannot write ArrayBuffer to file without encoding = buffer'); | ||||||
|  | 			} else if (encoding === 'utf-8' || encoding === 'utf8') { | ||||||
|  | 				const encoder = new TextEncoder(); | ||||||
|  | 				await write(encoder.encode(data)); | ||||||
|  | 			} else { | ||||||
|  | 				await write(Buffer.from(data, encoding).buffer); | ||||||
|  | 			} | ||||||
|  | 		} finally { | ||||||
|  | 			if (close) { | ||||||
|  | 				await close(); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		logger.debug('writeFile done', path); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async remove(path: string, { recursive = true }: RemoveOptions = {}) { | ||||||
|  | 		path = normalize(path); | ||||||
|  |  | ||||||
|  | 		this.directoryHandleCache_.clear(); | ||||||
|  |  | ||||||
|  | 		try { | ||||||
|  | 			const dirHandle = await this.pathToDirectoryHandle_(dirname(path)); | ||||||
|  |  | ||||||
|  | 			if (dirHandle) { | ||||||
|  | 				await dirHandle.removeEntry(basename(path), { recursive }); | ||||||
|  | 			} else { | ||||||
|  | 				console.warn(`remove: ENOENT: Parent directory of path ${JSON.stringify(path)} does not exist.`); | ||||||
|  | 			} | ||||||
|  | 		} catch (error) { | ||||||
|  | 			// remove should pass even if the item doesn't exist. | ||||||
|  | 			// This matches the behavior of fs-extra's remove. | ||||||
|  | 			if (!isNotFoundError(error)) { | ||||||
|  | 				throw error; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async unlink(path: string) { | ||||||
|  | 		return await this.remove(path, { recursive: false }); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async fileAtPath(path: string) { | ||||||
|  | 		path = normalize(path); | ||||||
|  |  | ||||||
|  | 		let file: File; | ||||||
|  | 		if (this.virtualFiles_.has(path)) { | ||||||
|  | 			file = this.virtualFiles_.get(path); | ||||||
|  | 		} else { | ||||||
|  | 			const handle = await this.pathToFileHandle_(path); | ||||||
|  | 			file = await handle.getFile(); | ||||||
|  | 		} | ||||||
|  | 		return file; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async readFile(path: string, encoding: BufferEncoding = 'utf-8') { | ||||||
|  | 		path = normalize(path); | ||||||
|  | 		logger.debug('readFile', path); | ||||||
|  | 		const file = await this.fileAtPath(path); | ||||||
|  |  | ||||||
|  | 		if (encoding === 'utf-8' || encoding === 'utf8') { | ||||||
|  | 			return await file.text(); | ||||||
|  | 		} else { | ||||||
|  | 			const buffer = Buffer.from(await file.arrayBuffer()); | ||||||
|  | 			return buffer.toString(encoding); | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async mkdir(path: string) { | ||||||
|  | 		if (path === externalDirectoryPrefix) { | ||||||
|  | 			return; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		logger.debug('mkdir', path); | ||||||
|  | 		await this.pathToDirectoryHandle_(path, true); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async copy(from: string, to: string) { | ||||||
|  | 		logger.debug('copy', from, to); | ||||||
|  | 		const fromFile = await this.fileAtPath(from); | ||||||
|  | 		await this.writeFile(to, await fromFile.arrayBuffer(), 'Buffer'); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async stat(path: string, handle?: FileSystemDirectoryHandle|FileSystemFileHandle): Promise<TransferableStat|null> { | ||||||
|  | 		logger.debug('stat', path, handle ? 'with handle' : ''); | ||||||
|  | 		handle ??= await this.pathToDirectoryHandle_(path); | ||||||
|  | 		try { | ||||||
|  | 			handle ??= await this.pathToFileHandle_(path); | ||||||
|  | 		} catch (error) { | ||||||
|  | 			// Should return null when a file isn't found. | ||||||
|  | 			if (!isNotFoundError(error)) { | ||||||
|  | 				throw error; | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		const virtualFile = this.virtualFiles_.get(normalize(path)); | ||||||
|  |  | ||||||
|  | 		if (!handle && !virtualFile) return null; | ||||||
|  | 		logger.debug('has handle'); | ||||||
|  |  | ||||||
|  | 		const file = await (async () => { | ||||||
|  | 			if (handle.kind === 'directory') return null; | ||||||
|  | 			return virtualFile ?? await handle.getFile(); | ||||||
|  | 		})(); | ||||||
|  | 		const lastModifiedTime = file?.lastModified ?? 0; | ||||||
|  |  | ||||||
|  | 		return { | ||||||
|  | 			birthtime: lastModifiedTime, | ||||||
|  | 			mtime: lastModifiedTime, | ||||||
|  | 			// Can't normalize protocol URIs (e.g. external:///foo) | ||||||
|  | 			path: path.match(/^[a-z]+:/) ? path : normalize(path), | ||||||
|  | 			size: file?.size ?? 0, | ||||||
|  | 			isDirectory: handle.kind === 'directory', | ||||||
|  | 		}; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async readDirStats(path: string, options: ReadDirStatsOptions = { recursive: false }): Promise<TransferableStat[]|null> { | ||||||
|  | 		const readDirStats = async (basePath: string, path: string, dirHandle?: FileSystemDirectoryHandle) => { | ||||||
|  | 			dirHandle ??= await this.pathToDirectoryHandle_(path); | ||||||
|  | 			if (!dirHandle) return null; | ||||||
|  |  | ||||||
|  | 			const result: TransferableStat[] = []; | ||||||
|  | 			try { | ||||||
|  | 				for await (const [childInternalName, childHandle] of dirHandle.entries()) { | ||||||
|  | 					const childFileName = restoreReservedWords(childInternalName); | ||||||
|  | 					const childPath = join(path, childFileName); | ||||||
|  |  | ||||||
|  | 					const stat = await this.stat(childPath, childHandle); | ||||||
|  | 					result.push({ ...stat, path: join(basePath, childFileName) }); | ||||||
|  |  | ||||||
|  | 					if (options.recursive && childHandle.kind === 'directory') { | ||||||
|  | 						const childBasePath = join(basePath, childFileName); | ||||||
|  | 						result.push(...await readDirStats(childBasePath, childPath, childHandle)); | ||||||
|  | 					} | ||||||
|  | 				} | ||||||
|  | 			} catch (error) { | ||||||
|  | 				if (isNotFoundError(error)) { | ||||||
|  | 					return null; | ||||||
|  | 				} else { | ||||||
|  | 					throw new Error(`readDirStats error: ${error}, path: ${basePath},${path}`); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			return result; | ||||||
|  | 		}; | ||||||
|  | 		return readDirStats('', path); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async exists(path: string) { | ||||||
|  | 		logger.debug('exists?', path); | ||||||
|  | 		path = resolve('/', path); | ||||||
|  |  | ||||||
|  | 		if (this.virtualFiles_.has(path) || this.externalHandles_.has(path)) { | ||||||
|  | 			return true; | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		const parentDirectory = await this.pathToDirectoryHandle_(dirname(path)); | ||||||
|  | 		if (!parentDirectory) return false; | ||||||
|  |  | ||||||
|  | 		const fileName = removeReservedWords(basename(path)); | ||||||
|  | 		try { | ||||||
|  | 			const childHandle = await parentDirectory.getFileHandle(fileName); | ||||||
|  | 			return !!childHandle; | ||||||
|  | 		} catch (error) { | ||||||
|  | 			if (isNotFoundError(error)) { | ||||||
|  | 				return false; | ||||||
|  | 			} else if (isTypeMismatchError(error)) { | ||||||
|  | 				// A file was requested, so the path is a directory. | ||||||
|  | 				return true; | ||||||
|  | 			} | ||||||
|  |  | ||||||
|  | 			throw error; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async md5File(path: string): Promise<string> { | ||||||
|  | 		const fileData = Buffer.from(await (await this.fileAtPath(path)).arrayBuffer()); | ||||||
|  | 		return md5(fileData); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async createReadOnlyVirtualFile(path: string, content: File) { | ||||||
|  | 		this.virtualFiles_.set(normalize(path), content); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	public async mountExternalDirectory(handle: FileSystemDirectoryHandle, id: string, mode: AccessMode) { | ||||||
|  | 		if (await handle.requestPermission({ mode }) !== 'granted') { | ||||||
|  | 			throw new Error(`${mode} access is needed for ${id}.`); | ||||||
|  | 		} | ||||||
|  |  | ||||||
|  | 		const mountPath = resolve(externalDirectoryPrefix, crypto.randomUUID().replace(/-/g, '')); | ||||||
|  | 		this.externalHandles_.set(mountPath, handle); | ||||||
|  |  | ||||||
|  | 		await this.accessHandleDatabase_.clearExternalHandle(id); | ||||||
|  | 		await this.accessHandleDatabase_.addExternalHandle(mountPath, id, handle, mode); | ||||||
|  |  | ||||||
|  | 		return mountPath; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface RemoteApi { } | ||||||
|  | new WorkerToWindowMessenger<WorkerApi, RemoteApi>('fs-worker', new WorkerApi()); | ||||||
| @@ -167,7 +167,7 @@ const testReadFileChunkUtf8 = async (tempDir: string) => { | |||||||
| 			await fsDriver.readFileChunk(handle, 1, encoding), null, | 			await fsDriver.readFileChunk(handle, 1, encoding), null, | ||||||
| 		); | 		); | ||||||
|  |  | ||||||
| 		await fsDriver.close(filePath); | 		await fsDriver.close(handle); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// Should throw when the file doesn't exist | 	// Should throw when the file doesn't exist | ||||||
| @@ -261,6 +261,7 @@ const runOnDeviceTests = async () => { | |||||||
| 	if (await shim.fsDriver().exists(tempDir)) { | 	if (await shim.fsDriver().exists(tempDir)) { | ||||||
| 		await shim.fsDriver().remove(tempDir); | 		await shim.fsDriver().remove(tempDir); | ||||||
| 	} | 	} | ||||||
|  | 	await shim.fsDriver().mkdir(tempDir); | ||||||
|  |  | ||||||
| 	try { | 	try { | ||||||
| 		await testExpect(); | 		await testExpect(); | ||||||
|   | |||||||
| @@ -1,6 +1,6 @@ | |||||||
| import { pack as tarStreamPack } from 'tar-stream'; | import { pack as tarStreamPack } from 'tar-stream'; | ||||||
| import { resolve } from 'path'; | import { resolve } from 'path'; | ||||||
| import * as RNFS from 'react-native-fs'; | import { Buffer } from 'buffer'; | ||||||
|  |  | ||||||
| import Logger from '@joplin/utils/Logger'; | import Logger from '@joplin/utils/Logger'; | ||||||
| import { chunkSize } from './constants'; | import { chunkSize } from './constants'; | ||||||
| @@ -8,7 +8,7 @@ import shim from '@joplin/lib/shim'; | |||||||
|  |  | ||||||
| const logger = Logger.create('fs-driver-rn'); | const logger = Logger.create('fs-driver-rn'); | ||||||
|  |  | ||||||
| interface TarCreateOptions { | export interface TarCreateOptions { | ||||||
| 	cwd: string; | 	cwd: string; | ||||||
| 	file: string; | 	file: string; | ||||||
| } | } | ||||||
| @@ -18,7 +18,7 @@ interface TarCreateOptions { | |||||||
|  |  | ||||||
| const tarCreate = async (options: TarCreateOptions, filePaths: string[]) => { | const tarCreate = async (options: TarCreateOptions, filePaths: string[]) => { | ||||||
| 	// Choose a default cwd if not given | 	// Choose a default cwd if not given | ||||||
| 	const cwd = options.cwd ?? RNFS.DocumentDirectoryPath; | 	const cwd = options.cwd ?? shim.fsDriver().getAppDirectoryPath(); | ||||||
| 	const file = resolve(cwd, options.file); | 	const file = resolve(cwd, options.file); | ||||||
|  |  | ||||||
| 	const fsDriver = shim.fsDriver(); | 	const fsDriver = shim.fsDriver(); | ||||||
| @@ -28,6 +28,12 @@ const tarCreate = async (options: TarCreateOptions, filePaths: string[]) => { | |||||||
|  |  | ||||||
| 	const pack = tarStreamPack(); | 	const pack = tarStreamPack(); | ||||||
|  |  | ||||||
|  | 	const errors: Error[] = []; | ||||||
|  | 	pack.addListener('error', error => { | ||||||
|  | 		logger.error(`Tar error: ${error}`); | ||||||
|  | 		errors.push(error); | ||||||
|  | 	}); | ||||||
|  |  | ||||||
| 	for (const path of filePaths) { | 	for (const path of filePaths) { | ||||||
| 		const absPath = resolve(cwd, path); | 		const absPath = resolve(cwd, path); | ||||||
| 		const stat = await fsDriver.stat(absPath); | 		const stat = await fsDriver.stat(absPath); | ||||||
| @@ -39,10 +45,16 @@ const tarCreate = async (options: TarCreateOptions, filePaths: string[]) => { | |||||||
| 			} | 			} | ||||||
| 		}); | 		}); | ||||||
|  |  | ||||||
| 		for (let offset = 0; offset < sizeBytes; offset += chunkSize) { | 		const handle = await shim.fsDriver().open(absPath, 'rw'); | ||||||
| 			// The RNFS documentation suggests using base64 for binary files. |  | ||||||
| 			const part = await RNFS.read(absPath, chunkSize, offset, 'base64'); | 		let offset = 0; | ||||||
| 			entry.write(Buffer.from(part, 'base64')); | 		let lastOffset = -1; | ||||||
|  | 		while (offset < sizeBytes && offset !== lastOffset) { | ||||||
|  | 			const part = await shim.fsDriver().readFileChunkAsBuffer(handle, chunkSize); | ||||||
|  | 			entry.write(part); | ||||||
|  |  | ||||||
|  | 			lastOffset = offset; | ||||||
|  | 			offset += part.byteLength; | ||||||
| 		} | 		} | ||||||
| 		entry.end(); | 		entry.end(); | ||||||
| 	} | 	} | ||||||
| @@ -57,6 +69,10 @@ const tarCreate = async (options: TarCreateOptions, filePaths: string[]) => { | |||||||
| 		const base64Data = buff.toString('base64'); | 		const base64Data = buff.toString('base64'); | ||||||
| 		await fsDriver.appendFile(file, base64Data, 'base64'); | 		await fsDriver.appendFile(file, base64Data, 'base64'); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	if (errors.length) { | ||||||
|  | 		throw new Error(`tarCreate errors: ${errors.map(e => `Error: ${e}, stack: ${e?.stack}`)}`); | ||||||
|  | 	} | ||||||
| }; | }; | ||||||
|  |  | ||||||
| export default tarCreate; | export default tarCreate; | ||||||
|   | |||||||
| @@ -3,7 +3,7 @@ import { resolve, dirname } from 'path'; | |||||||
| import shim from '@joplin/lib/shim'; | import shim from '@joplin/lib/shim'; | ||||||
| import { chunkSize } from './constants'; | import { chunkSize } from './constants'; | ||||||
|  |  | ||||||
| interface TarExtractOptions { | export interface TarExtractOptions { | ||||||
| 	cwd: string; | 	cwd: string; | ||||||
| 	file: string; | 	file: string; | ||||||
| } | } | ||||||
| @@ -68,8 +68,7 @@ const tarExtract = async (options: TarExtractOptions) => { | |||||||
|  |  | ||||||
| 	const fileHandle = await fsDriver.open(filePath, 'r'); | 	const fileHandle = await fsDriver.open(filePath, 'r'); | ||||||
| 	const readChunk = async () => { | 	const readChunk = async () => { | ||||||
| 		const base64 = await fsDriver.readFileChunk(fileHandle, chunkSize, 'base64'); | 		return await fsDriver.readFileChunkAsBuffer(fileHandle, chunkSize); | ||||||
| 		return base64 && Buffer.from(base64, 'base64'); |  | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	try { | 	try { | ||||||
|   | |||||||
| @@ -19,8 +19,12 @@ const getWebViewVersionText = () => { | |||||||
| const getOSVersion = (): string => { | const getOSVersion = (): string => { | ||||||
| 	if (Platform.OS === 'android') { | 	if (Platform.OS === 'android') { | ||||||
| 		return _('Android API level: %d', Platform.Version); | 		return _('Android API level: %d', Platform.Version); | ||||||
| 	} else { | 	} else if (Platform.OS === 'ios') { | ||||||
| 		return _('iOS version: %s', Platform.Version); | 		return _('iOS version: %s', Platform.Version); | ||||||
|  | 	} else if (Platform.OS === 'web') { | ||||||
|  | 		return `User agent: ${navigator.userAgent}`; | ||||||
|  | 	} else { | ||||||
|  | 		return _('Unknown platform'); | ||||||
| 	} | 	} | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										28
									
								
								packages/app-mobile/utils/image/fileToImage.web.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								packages/app-mobile/utils/image/fileToImage.web.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | |||||||
|  | import shim from '@joplin/lib/shim'; | ||||||
|  | import { Platform } from 'react-native'; | ||||||
|  |  | ||||||
|  | const fileToImage = async (path: string) => { | ||||||
|  | 	if (Platform.OS !== 'web') throw new Error('fileToImageUrl: Not supported'); | ||||||
|  |  | ||||||
|  | 	const image = new Image(); | ||||||
|  | 	const objectUrl = URL.createObjectURL(await shim.fsDriver().fileAtPath(path)); | ||||||
|  | 	const free = () => URL.revokeObjectURL(objectUrl); | ||||||
|  |  | ||||||
|  | 	try { | ||||||
|  | 		image.src = objectUrl; | ||||||
|  | 		await new Promise<void>((resolve, reject) => { | ||||||
|  | 			image.onload = () => resolve(); | ||||||
|  | 			image.onerror = (event) => reject(new Error(`Error loading: ${event}`)); | ||||||
|  | 			image.onabort = (event) => reject(new Error(`Loading cancelled: ${event}`)); | ||||||
|  | 		}); | ||||||
|  | 	} finally { | ||||||
|  | 		free(); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	return { | ||||||
|  | 		image, | ||||||
|  | 		free, | ||||||
|  | 	}; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default fileToImage; | ||||||
| @@ -1,11 +1,25 @@ | |||||||
| import { Size } from '@joplin/utils/types'; | import { Size } from '@joplin/utils/types'; | ||||||
| import { Image as NativeImage } from 'react-native'; | import { fileUriToPath } from '@joplin/utils/url'; | ||||||
|  | import { Image as NativeImage, Platform } from 'react-native'; | ||||||
|  | import fileToImage from './fileToImage.web'; | ||||||
|  |  | ||||||
|  |  | ||||||
| const getImageDimensions = async (uri: string): Promise<Size> => { | const getImageDimensions = async (uri: string): Promise<Size> => { | ||||||
| 	if (uri.startsWith('/')) { | 	if (uri.startsWith('/')) { | ||||||
| 		uri = `file://${uri}`; | 		uri = `file://${uri}`; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	// On web, image files are stored using the Origin Private File System and need special | ||||||
|  | 	// handling. | ||||||
|  | 	const isFileUrl = uri.startsWith('file://'); | ||||||
|  | 	if (Platform.OS === 'web' && isFileUrl) { | ||||||
|  | 		const path = isFileUrl ? fileUriToPath(uri) : uri; | ||||||
|  | 		const image = await fileToImage(path); | ||||||
|  | 		const size = { width: image.image.width, height: image.image.height }; | ||||||
|  | 		image.free(); | ||||||
|  | 		return size; | ||||||
|  | 	} | ||||||
|  |  | ||||||
| 	return new Promise((resolve, reject) => { | 	return new Promise((resolve, reject) => { | ||||||
| 		NativeImage.getSize( | 		NativeImage.getSize( | ||||||
| 			uri, | 			uri, | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| import shim from '@joplin/lib/shim'; | import shim from '@joplin/lib/shim'; | ||||||
| import Logger from '@joplin/utils/Logger'; | import Logger from '@joplin/utils/Logger'; | ||||||
| import ImageResizer from '@bam.tech/react-native-image-resizer'; | import ImageResizer from '@bam.tech/react-native-image-resizer'; | ||||||
|  | import fileToImage from './fileToImage.web'; | ||||||
|  | import FsDriverWeb from '../fs-driver/fs-driver-rn.web'; | ||||||
|  |  | ||||||
| const logger = Logger.create('resizeImage'); | const logger = Logger.create('resizeImage'); | ||||||
|  |  | ||||||
| @@ -16,27 +18,64 @@ interface Options { | |||||||
| } | } | ||||||
|  |  | ||||||
| const resizeImage = async (options: Options) => { | const resizeImage = async (options: Options) => { | ||||||
| 	const resizedImage = await ImageResizer.createResizedImage( | 	if (shim.mobilePlatform() === 'web') { | ||||||
| 		options.inputPath, | 		const image = await fileToImage(options.inputPath); | ||||||
| 		options.maxWidth, | 		try { | ||||||
| 		options.maxHeight, | 			const canvas = document.createElement('canvas'); | ||||||
| 		options.format, |  | ||||||
| 		options.quality, // quality |  | ||||||
| 		undefined, // rotation |  | ||||||
| 		undefined, // outputPath |  | ||||||
| 		true, // keep metadata |  | ||||||
| 	); |  | ||||||
|  |  | ||||||
| 	const resizedImagePath = resizedImage.uri; | 			// Choose a scale factor such that the resized image fits within a | ||||||
| 	logger.info('Resized image ', resizedImagePath); | 			// maxWidth x maxHeight box. | ||||||
| 	logger.info(`Moving ${resizedImagePath} => ${options.outputPath}`); | 			const scale = Math.min( | ||||||
|  | 				options.maxWidth / image.image.width, | ||||||
|  | 				options.maxHeight / image.image.height, | ||||||
|  | 			); | ||||||
|  | 			canvas.width = image.image.width * scale; | ||||||
|  | 			canvas.height = image.image.height * scale; | ||||||
|  |  | ||||||
| 	await shim.fsDriver().copy(resizedImagePath, options.outputPath); | 			const ctx = canvas.getContext('2d'); | ||||||
|  | 			ctx.drawImage(image.image, 0, 0, canvas.width, canvas.height); | ||||||
|  |  | ||||||
| 	try { | 			const blob = await new Promise<Blob>((resolve, reject) => { | ||||||
| 		await shim.fsDriver().unlink(resizedImagePath); | 				try { | ||||||
| 	} catch (error) { | 					canvas.toBlob( | ||||||
| 		logger.warn('Error when unlinking cached file: ', error); | 						(blob) => resolve(blob), | ||||||
|  | 						`image/${options.format.toLowerCase()}`, | ||||||
|  | 						options.quality, | ||||||
|  | 					); | ||||||
|  | 				} catch (error) { | ||||||
|  | 					reject(error); | ||||||
|  | 				} | ||||||
|  | 			}); | ||||||
|  |  | ||||||
|  | 			await (shim.fsDriver() as FsDriverWeb).writeFile( | ||||||
|  | 				options.outputPath, await blob.arrayBuffer(), 'Buffer', | ||||||
|  | 			); | ||||||
|  | 		} finally { | ||||||
|  | 			image.free(); | ||||||
|  | 		} | ||||||
|  | 	} else { | ||||||
|  | 		const resizedImage = await ImageResizer.createResizedImage( | ||||||
|  | 			options.inputPath, | ||||||
|  | 			options.maxWidth, | ||||||
|  | 			options.maxHeight, | ||||||
|  | 			options.format, | ||||||
|  | 			options.quality, // quality | ||||||
|  | 			undefined, // rotation | ||||||
|  | 			undefined, // outputPath | ||||||
|  | 			true, // keep metadata | ||||||
|  | 		); | ||||||
|  |  | ||||||
|  | 		const resizedImagePath = resizedImage.uri; | ||||||
|  | 		logger.info('Resized image ', resizedImagePath); | ||||||
|  | 		logger.info(`Moving ${resizedImagePath} => ${options.outputPath}`); | ||||||
|  |  | ||||||
|  | 		await shim.fsDriver().copy(resizedImagePath, options.outputPath); | ||||||
|  |  | ||||||
|  | 		try { | ||||||
|  | 			await shim.fsDriver().unlink(resizedImagePath); | ||||||
|  | 		} catch (error) { | ||||||
|  | 			logger.warn('Error when unlinking cached file: ', error); | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,9 +1,12 @@ | |||||||
|  |  | ||||||
| import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger'; | import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger'; | ||||||
| import { SerializableData } from '@joplin/lib/utils/ipc/types'; | import { SerializableData } from '@joplin/lib/utils/ipc/types'; | ||||||
| import { WebViewControl } from '../../components/ExtendedWebView'; | import { WebViewControl } from '../../components/ExtendedWebView/types'; | ||||||
| import { RefObject } from 'react'; | import { RefObject } from 'react'; | ||||||
| import { OnMessageEvent } from '../../components/ExtendedWebView/types'; | import { OnMessageEvent } from '../../components/ExtendedWebView/types'; | ||||||
|  | import { Platform } from 'react-native'; | ||||||
|  |  | ||||||
|  | const canUseOptimizedPostMessage = Platform.OS === 'web'; | ||||||
|  |  | ||||||
| export default class RNToWebViewMessenger<LocalInterface, RemoteInterface> extends RemoteMessenger<LocalInterface, RemoteInterface> { | export default class RNToWebViewMessenger<LocalInterface, RemoteInterface> extends RemoteMessenger<LocalInterface, RemoteInterface> { | ||||||
| 	public constructor(channelId: string, private webviewControl: WebViewControl|RefObject<WebViewControl>, localApi: LocalInterface) { | 	public constructor(channelId: string, private webviewControl: WebViewControl|RefObject<WebViewControl>, localApi: LocalInterface) { | ||||||
| @@ -19,22 +22,30 @@ export default class RNToWebViewMessenger<LocalInterface, RemoteInterface> exten | |||||||
| 		// This is the case in testing environments where no WebView is available. | 		// This is the case in testing environments where no WebView is available. | ||||||
| 		if (!webviewControl.injectJS) return; | 		if (!webviewControl.injectJS) return; | ||||||
|  |  | ||||||
| 		webviewControl.injectJS(` | 		if (canUseOptimizedPostMessage) { | ||||||
| 			window.dispatchEvent( | 			webviewControl.postMessage(message); | ||||||
| 				new MessageEvent( | 		} else { | ||||||
| 					'message', | 			webviewControl.injectJS(` | ||||||
| 					{ | 				window.dispatchEvent( | ||||||
| 						data: ${JSON.stringify(message)}, | 					new MessageEvent( | ||||||
| 						origin: 'react-native' | 						'message', | ||||||
| 					}, | 						{ | ||||||
| 				), | 							data: ${JSON.stringify(message)}, | ||||||
| 			); | 							origin: 'react-native' | ||||||
| 		`); | 						}, | ||||||
|  | 					), | ||||||
|  | 				); | ||||||
|  | 			`); | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	public onWebViewMessage = (event: OnMessageEvent) => { | 	public onWebViewMessage = (event: OnMessageEvent) => { | ||||||
| 		if (!this.hasBeenClosed()) { | 		if (!this.hasBeenClosed()) { | ||||||
| 			void this.onMessage(JSON.parse(event.nativeEvent.data)); | 			if (canUseOptimizedPostMessage) { | ||||||
|  | 				void this.onMessage(event.nativeEvent.data); | ||||||
|  | 			} else { | ||||||
|  | 				void this.onMessage(JSON.parse(event.nativeEvent.data)); | ||||||
|  | 			} | ||||||
| 		} | 		} | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -2,6 +2,14 @@ | |||||||
| import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger'; | import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger'; | ||||||
| import { SerializableData } from '@joplin/lib/utils/ipc/types'; | import { SerializableData } from '@joplin/lib/utils/ipc/types'; | ||||||
|  |  | ||||||
|  | interface ExtendedWindow extends Window { | ||||||
|  | 	ReactNativeWebView: { | ||||||
|  | 		postMessage(message: unknown): void; | ||||||
|  | 		supportsNonStringMessages?: boolean; | ||||||
|  | 	}; | ||||||
|  | } | ||||||
|  | declare const window: ExtendedWindow; | ||||||
|  |  | ||||||
| export default class WebViewToRNMessenger<LocalInterface, RemoteInterface> extends RemoteMessenger<LocalInterface, RemoteInterface> { | export default class WebViewToRNMessenger<LocalInterface, RemoteInterface> extends RemoteMessenger<LocalInterface, RemoteInterface> { | ||||||
| 	public constructor(channelId: string, localApi: LocalInterface) { | 	public constructor(channelId: string, localApi: LocalInterface) { | ||||||
| 		super(channelId, localApi); | 		super(channelId, localApi); | ||||||
| @@ -24,8 +32,7 @@ export default class WebViewToRNMessenger<LocalInterface, RemoteInterface> exten | |||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
| 	protected override postMessage(message: SerializableData): void { | 	protected override postMessage(message: SerializableData): void { | ||||||
| 		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | 		window.ReactNativeWebView.postMessage(window.ReactNativeWebView.supportsNonStringMessages ? message : JSON.stringify(message)); | ||||||
| 		(window as any).ReactNativeWebView.postMessage(JSON.stringify(message)); |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	protected override onClose(): void { | 	protected override onClose(): void { | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								packages/app-mobile/utils/lockToSingleInstance.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								packages/app-mobile/utils/lockToSingleInstance.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | |||||||
|  | import { _ } from '@joplin/lib/locale'; | ||||||
|  | import { Platform } from 'react-native'; | ||||||
|  |  | ||||||
|  | const lockToSingleInstance = async () => { | ||||||
|  | 	if (Platform.OS !== 'web') return; | ||||||
|  |  | ||||||
|  | 	const channel = new BroadcastChannel('single-instance-lock'); | ||||||
|  | 	channel.postMessage('app-opened'); | ||||||
|  |  | ||||||
|  | 	await new Promise<void>((resolve, reject) => { | ||||||
|  | 		channel.onmessage = (event) => { | ||||||
|  | 			if (event.data === 'app-opened') { | ||||||
|  | 				channel.postMessage('already-running'); | ||||||
|  | 			} else if (event.data === 'already-running') { | ||||||
|  | 				alert(_('At present, Joplin Web can only be open in one tab at a time. Please close the other instance of Joplin.')); | ||||||
|  | 				reject(new Error(_('Joplin is already running.'))); | ||||||
|  | 			} | ||||||
|  | 		}; | ||||||
|  | 		setTimeout(() => resolve(), 250); | ||||||
|  | 	}); | ||||||
|  | }; | ||||||
|  | export default lockToSingleInstance; | ||||||
| @@ -1,14 +1,16 @@ | |||||||
| import { _ } from '@joplin/lib/locale'; | import { _ } from '@joplin/lib/locale'; | ||||||
| import { Alert, AlertButton } from 'react-native'; | import { Alert } from 'react-native'; | ||||||
|  | import { DialogControl, PromptButton } from '../components/DialogManager'; | ||||||
|  | import { RefObject } from 'react'; | ||||||
| 
 | 
 | ||||||
| interface Options { | interface Options { | ||||||
| 	title: string; | 	title: string; | ||||||
| 	buttons: string[]; | 	buttons: string[]; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const showMessageBox = (message: string, options: Options = null) => { | const makeShowMessageBox = (dialogControl: null|RefObject<DialogControl>) => (message: string, options: Options = null) => { | ||||||
| 	return new Promise<number>(resolve => { | 	return new Promise<number>(resolve => { | ||||||
| 		const defaultButtons: AlertButton[] = [ | 		const defaultButtons: PromptButton[] = [ | ||||||
| 			{ | 			{ | ||||||
| 				text: _('OK'), | 				text: _('OK'), | ||||||
| 				onPress: () => resolve(0), | 				onPress: () => resolve(0), | ||||||
| @@ -30,11 +32,12 @@ const showMessageBox = (message: string, options: Options = null) => { | |||||||
| 			}); | 			}); | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		Alert.alert( | 		// Web doesn't support Alert.alert -- prefer using the global dialogControl if available.
 | ||||||
|  | 		(dialogControl?.current?.prompt ?? Alert.alert)( | ||||||
| 			options?.title ?? '', | 			options?.title ?? '', | ||||||
| 			message, | 			message, | ||||||
| 			buttons, | 			buttons, | ||||||
| 		); | 		); | ||||||
| 	}); | 	}); | ||||||
| }; | }; | ||||||
| export default showMessageBox; | export default makeShowMessageBox; | ||||||
| @@ -2,6 +2,8 @@ import shim from '@joplin/lib/shim'; | |||||||
| import DocumentPicker, { DocumentPickerResponse } from 'react-native-document-picker'; | import DocumentPicker, { DocumentPickerResponse } from 'react-native-document-picker'; | ||||||
| import { openDocument } from '@joplin/react-native-saf-x'; | import { openDocument } from '@joplin/react-native-saf-x'; | ||||||
| import Logger from '@joplin/utils/Logger'; | import Logger from '@joplin/utils/Logger'; | ||||||
|  | import type FsDriverWeb from './fs-driver/fs-driver-rn.web'; | ||||||
|  | import uuid from '@joplin/lib/uuid'; | ||||||
|  |  | ||||||
| interface SelectedDocument { | interface SelectedDocument { | ||||||
| 	type: string; | 	type: string; | ||||||
| @@ -12,10 +14,58 @@ interface SelectedDocument { | |||||||
|  |  | ||||||
| const logger = Logger.create('pickDocument'); | const logger = Logger.create('pickDocument'); | ||||||
|  |  | ||||||
| const pickDocument = async (multiple: boolean): Promise<SelectedDocument[]> => { | interface Options { | ||||||
|  | 	multiple?: boolean; | ||||||
|  | 	preferCamera?: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const pickDocument = async ({ multiple = false, preferCamera = false }: Options = {}): Promise<SelectedDocument[]> => { | ||||||
| 	let result: SelectedDocument[] = []; | 	let result: SelectedDocument[] = []; | ||||||
| 	try { | 	try { | ||||||
| 		if (shim.fsDriver().isUsingAndroidSAF()) { | 		if (shim.mobilePlatform() === 'web') { | ||||||
|  | 			await new Promise<void>((resolve, reject) => { | ||||||
|  | 				const input = document.createElement('input'); | ||||||
|  | 				input.type = 'file'; | ||||||
|  | 				input.style.display = 'none'; | ||||||
|  | 				input.multiple = multiple; | ||||||
|  | 				if (preferCamera) { | ||||||
|  | 					input.capture = 'environment'; | ||||||
|  | 					input.accept = 'image/*'; | ||||||
|  | 				} | ||||||
|  | 				document.body.appendChild(input); | ||||||
|  |  | ||||||
|  | 				input.onchange = async () => { | ||||||
|  | 					try { | ||||||
|  | 						const fsDriver = shim.fsDriver() as FsDriverWeb; | ||||||
|  | 						if (input.files.length > 0) { | ||||||
|  | 							for (const file of input.files) { | ||||||
|  | 								const path = `/tmp/${uuid.create()}`; | ||||||
|  | 								await fsDriver.createReadOnlyVirtualFile(path, file); | ||||||
|  |  | ||||||
|  | 								result.push({ | ||||||
|  | 									type: file.type, | ||||||
|  | 									mime: file.type, | ||||||
|  | 									uri: path, | ||||||
|  | 									fileName: file.name, | ||||||
|  | 								}); | ||||||
|  | 							} | ||||||
|  | 						} | ||||||
|  | 						resolve(); | ||||||
|  | 					} catch (error) { | ||||||
|  | 						reject(error); | ||||||
|  | 					} finally { | ||||||
|  | 						input.remove(); | ||||||
|  | 					} | ||||||
|  | 				}; | ||||||
|  |  | ||||||
|  | 				input.oncancel = () => { | ||||||
|  | 					input.remove(); | ||||||
|  | 					resolve(); | ||||||
|  | 				}; | ||||||
|  |  | ||||||
|  | 				input.click(); | ||||||
|  | 			}); | ||||||
|  | 		} else if (shim.fsDriver().isUsingAndroidSAF()) { | ||||||
| 			const openDocResult = await openDocument({ multiple }); | 			const openDocResult = await openDocument({ multiple }); | ||||||
| 			if (!openDocResult) { | 			if (!openDocResult) { | ||||||
| 				throw new Error('User canceled document picker'); | 				throw new Error('User canceled document picker'); | ||||||
| @@ -48,7 +98,7 @@ const pickDocument = async (multiple: boolean): Promise<SelectedDocument[]> => { | |||||||
| 			}); | 			}); | ||||||
| 		} | 		} | ||||||
| 	} catch (error) { | 	} catch (error) { | ||||||
| 		if (DocumentPicker.isCancel(error) || error?.message?.includes('cancel')) { | 		if (DocumentPicker?.isCancel?.(error) || error?.message?.includes('cancel')) { | ||||||
| 			logger.info('user has cancelled'); | 			logger.info('user has cancelled'); | ||||||
| 			return []; | 			return []; | ||||||
| 		} else { | 		} else { | ||||||
|   | |||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user