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-mobile/android | ||||
| packages/app-mobile/**/*.bundle.js | ||||
| packages/app-mobile/web/public/pluginAssets/**/* | ||||
| packages/app-mobile/ios | ||||
| packages/app-mobile/lib/rnInjectedJs/ | ||||
| packages/app-mobile/locales | ||||
| @@ -529,15 +530,17 @@ packages/app-mobile/commands/openItem.js | ||||
| packages/app-mobile/commands/openNote.js | ||||
| packages/app-mobile/commands/scrollToHash.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/BetaChip.js | ||||
| packages/app-mobile/components/CameraView.js | ||||
| packages/app-mobile/components/DialogManager.js | ||||
| packages/app-mobile/components/DismissibleDialog.js | ||||
| packages/app-mobile/components/Dropdown.test.js | ||||
| packages/app-mobile/components/Dropdown.js | ||||
| packages/app-mobile/components/ExtendedWebView/index.jest.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/FolderPicker.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.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/SelectDateTimeDialog.js | ||||
| packages/app-mobile/components/SideMenu.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/base-screen.js | ||||
| packages/app-mobile/components/biometrics/BiometricPopup.js | ||||
| packages/app-mobile/components/biometrics/biometricAuthenticate.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/index.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/utils/getFormData.test.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/wrapConsoleLog.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/voiceTyping/VoiceTypingDialog.js | ||||
| packages/app-mobile/gulpfile.js | ||||
| packages/app-mobile/index.web.js | ||||
| packages/app-mobile/root.js | ||||
| packages/app-mobile/services/AlarmServiceDriver.android.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/plugins/PlatformImplementation.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/copyJs.js | ||||
| packages/app-mobile/tools/buildInjectedJs/gulpTasks.js | ||||
| packages/app-mobile/tools/copyAssets.js | ||||
| packages/app-mobile/utils/ShareExtension.js | ||||
| packages/app-mobile/utils/ShareUtils.test.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/checkPermissions.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/fs-driver/constants.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/tarCreate.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/getPackageInfo.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/resizeImage.js | ||||
| packages/app-mobile/utils/initializeCommandService.js | ||||
| packages/app-mobile/utils/injectedJs.js | ||||
| packages/app-mobile/utils/ipc/RNToWebViewMessenger.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/polyfills/bufferPolyfill.js | ||||
| packages/app-mobile/utils/polyfills/index.js | ||||
| packages/app-mobile/utils/setupNotifications.js | ||||
| packages/app-mobile/utils/shareFile.js | ||||
| packages/app-mobile/utils/shareHandler.js | ||||
| packages/app-mobile/utils/shim-init-react.js | ||||
| packages/app-mobile/utils/showMessageBox.js | ||||
| packages/app-mobile/utils/shim-init-react/index.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/getWebViewDomById.js | ||||
| packages/app-mobile/utils/types.js | ||||
| packages/app-mobile/web/serviceWorker.js | ||||
| packages/default-plugins/build.js | ||||
| packages/default-plugins/buildDefaultPlugins.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.js | ||||
| packages/lib/utils/credentialFiles.js | ||||
| packages/lib/utils/dom/makeSandboxedIframe.js | ||||
| packages/lib/utils/focusHandler.js | ||||
| packages/lib/utils/frontMatter.js | ||||
| packages/lib/utils/ipc/RemoteMessenger.test.js | ||||
| packages/lib/utils/ipc/RemoteMessenger.js | ||||
| packages/lib/utils/ipc/TestMessenger.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/utils/isTransferableObject.js | ||||
| packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.test.js | ||||
| packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.js | ||||
| packages/lib/utils/ipc/utils/separateCallbacksFromSerializable.test.js | ||||
|   | ||||
							
								
								
									
										12
									
								
								.eslintrc.js
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								.eslintrc.js
									
									
									
									
									
								
							| @@ -15,6 +15,18 @@ module.exports = { | ||||
| 	'globals': { | ||||
| 		'Atomics': '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 | ||||
| 		'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/scrollToHash.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/BetaChip.js | ||||
| packages/app-mobile/components/CameraView.js | ||||
| packages/app-mobile/components/DialogManager.js | ||||
| packages/app-mobile/components/DismissibleDialog.js | ||||
| packages/app-mobile/components/Dropdown.test.js | ||||
| packages/app-mobile/components/Dropdown.js | ||||
| packages/app-mobile/components/ExtendedWebView/index.jest.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/FolderPicker.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.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/SelectDateTimeDialog.js | ||||
| packages/app-mobile/components/SideMenu.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/base-screen.js | ||||
| packages/app-mobile/components/biometrics/BiometricPopup.js | ||||
| packages/app-mobile/components/biometrics/biometricAuthenticate.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/index.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/utils/getFormData.test.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/wrapConsoleLog.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/voiceTyping/VoiceTypingDialog.js | ||||
| packages/app-mobile/gulpfile.js | ||||
| packages/app-mobile/index.web.js | ||||
| packages/app-mobile/root.js | ||||
| packages/app-mobile/services/AlarmServiceDriver.android.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/plugins/PlatformImplementation.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/copyJs.js | ||||
| packages/app-mobile/tools/buildInjectedJs/gulpTasks.js | ||||
| packages/app-mobile/tools/copyAssets.js | ||||
| packages/app-mobile/utils/ShareExtension.js | ||||
| packages/app-mobile/utils/ShareUtils.test.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/checkPermissions.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/fs-driver/constants.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/tarCreate.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/getPackageInfo.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/resizeImage.js | ||||
| packages/app-mobile/utils/initializeCommandService.js | ||||
| packages/app-mobile/utils/injectedJs.js | ||||
| packages/app-mobile/utils/ipc/RNToWebViewMessenger.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/polyfills/bufferPolyfill.js | ||||
| packages/app-mobile/utils/polyfills/index.js | ||||
| packages/app-mobile/utils/setupNotifications.js | ||||
| packages/app-mobile/utils/shareFile.js | ||||
| packages/app-mobile/utils/shareHandler.js | ||||
| packages/app-mobile/utils/shim-init-react.js | ||||
| packages/app-mobile/utils/showMessageBox.js | ||||
| packages/app-mobile/utils/shim-init-react/index.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/getWebViewDomById.js | ||||
| packages/app-mobile/utils/types.js | ||||
| packages/app-mobile/web/serviceWorker.js | ||||
| packages/default-plugins/build.js | ||||
| packages/default-plugins/buildDefaultPlugins.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.js | ||||
| packages/lib/utils/credentialFiles.js | ||||
| packages/lib/utils/dom/makeSandboxedIframe.js | ||||
| packages/lib/utils/focusHandler.js | ||||
| packages/lib/utils/frontMatter.js | ||||
| packages/lib/utils/ipc/RemoteMessenger.test.js | ||||
| packages/lib/utils/ipc/RemoteMessenger.js | ||||
| packages/lib/utils/ipc/TestMessenger.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/utils/isTransferableObject.js | ||||
| packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.test.js | ||||
| packages/lib/utils/ipc/utils/mergeCallbacksAndSerializable.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.md5 | ||||
| components/**/*.bundle.min.js | ||||
| web/public/pluginAssets/* | ||||
|  | ||||
| utils/fs-driver-android.js | ||||
| android/app/build-* | ||||
|   | ||||
| @@ -3,12 +3,14 @@ const { dirname } = require('@joplin/lib/path-utils'); | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| const pluginAssets = require('./pluginAssets/index'); | ||||
| 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 { | ||||
|  | ||||
| 	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() { | ||||
| 		if (PluginAssetsLoader.instance_) return PluginAssetsLoader.instance_; | ||||
| @@ -16,39 +18,58 @@ export default class PluginAssetsLoader { | ||||
| 		return PluginAssetsLoader.instance_; | ||||
| 	} | ||||
|  | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	public setLogger(logger: any) { | ||||
| 		this.logger_ = logger; | ||||
| 	private destDir_() { | ||||
| 		return `${Setting.value('resourceDir')}/pluginAssets`; | ||||
| 	} | ||||
|  | ||||
| 	public logger() { | ||||
| 		return this.logger_; | ||||
| 	} | ||||
| 	private async importAssetsMobile_() { | ||||
| 		const destDir = this.destDir_(); | ||||
|  | ||||
| 	public async importAssets() { | ||||
| 		const destDir = `${Setting.value('resourceDir')}/pluginAssets`; | ||||
| 		await shim.fsDriver().mkdir(destDir); | ||||
|  | ||||
| 		const hash = pluginAssets.hash; | ||||
| 		if (hash === await KvStore.instance().value('PluginAssetsLoader.lastHash')) { | ||||
| 			this.logger().info(`PluginAssetsLoader: Assets are up to date. Hash: ${hash}`); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		this.logger().info(`PluginAssetsLoader: Importing assets to ${destDir}`); | ||||
|  | ||||
| 		try { | ||||
| 		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); | ||||
|  | ||||
| 				this.logger().info(`PluginAssetsLoader: Copying: ${name} => ${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() { | ||||
| 		const destDir = this.destDir_(); | ||||
| 		await shim.fsDriver().mkdir(destDir); | ||||
|  | ||||
| 		const hash = pluginAssets.hash; | ||||
| 		if (hash === await KvStore.instance().value('PluginAssetsLoader.lastHash')) { | ||||
| 			logger.info(`PluginAssetsLoader: Assets are up to date. Hash: ${hash}`); | ||||
| 			return; | ||||
| 		} | ||||
|  | ||||
| 		logger.info(`PluginAssetsLoader: Importing assets to ${destDir}`); | ||||
|  | ||||
| 		try { | ||||
| 			if (shim.mobilePlatform() === 'web') { | ||||
| 				await this.importAssetsWeb_(); | ||||
| 			} else { | ||||
| 				await this.importAssetsMobile_(); | ||||
| 			} | ||||
| 		} catch (error) { | ||||
| 			this.logger().error(error); | ||||
| 			logger.error(error); | ||||
| 		} | ||||
|  | ||||
| 		await KvStore.instance().setValue('PluginAssetsLoader.lastHash', hash); | ||||
|   | ||||
| @@ -4,6 +4,10 @@ import { _ } from '@joplin/lib/locale'; | ||||
| import { parseResourceUrl, urlProtocol } from '@joplin/lib/urlUtils'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| 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'); | ||||
|  | ||||
| @@ -22,7 +26,14 @@ export const runtime = (): CommandRuntime => { | ||||
| 					const { itemId, hash } = parsedUrl; | ||||
|  | ||||
| 					logger.info(`Navigating to item ${itemId}`); | ||||
| 					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 { | ||||
| 					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; | ||||
|  | ||||
| 		return StyleSheet.create({ | ||||
| 			webView: { | ||||
| 				backgroundColor: 'transparent', | ||||
| 				display: 'flex', | ||||
| 			}, | ||||
| 			webViewContainer: { | ||||
| 				flexGrow: 1, | ||||
| 				flexShrink: 1, | ||||
| 			}, | ||||
| 			closeButtonContainer: { | ||||
| 				flexDirection: 'row', | ||||
| 				justifyContent: 'flex-end', | ||||
|   | ||||
| @@ -115,6 +115,7 @@ class Dropdown extends Component<DropdownProps, DropdownState> { | ||||
| 		const itemWrapperStyle: ViewStyle = { | ||||
| 			...(this.props.itemWrapperStyle ? this.props.itemWrapperStyle : {}), | ||||
| 			flex: 1, | ||||
| 			flexBasis: 'auto', | ||||
| 			justifyContent: 'center', | ||||
| 			height: itemHeight, | ||||
| 			paddingLeft: 20, | ||||
| @@ -197,6 +198,7 @@ class Dropdown extends Component<DropdownProps, DropdownState> { | ||||
| 						style={headerWrapperStyle} | ||||
| 						disabled={this.props.disabled} | ||||
| 						onPress={this.onOpenList} | ||||
| 						role='button' | ||||
| 					> | ||||
| 						<Text ellipsizeMode="tail" numberOfLines={1} style={headerStyle}> | ||||
| 							{headerLabel} | ||||
| @@ -215,6 +217,7 @@ class Dropdown extends Component<DropdownProps, DropdownState> { | ||||
| 					<TouchableWithoutFeedback | ||||
| 						accessibilityElementsHidden={true} | ||||
| 						importantForAccessibility='no-hide-descendants' | ||||
| 						aria-hidden={true} | ||||
| 						onPress={this.onCloseList} | ||||
| 						style={backgroundCloseButtonStyle} | ||||
| 					> | ||||
|   | ||||
| @@ -15,8 +15,6 @@ import { Props, WebViewControl } from './types'; | ||||
|  | ||||
| const logger = Logger.create('ExtendedWebView'); | ||||
|  | ||||
| export { WebViewControl, Props }; | ||||
|  | ||||
| const ExtendedWebView = (props: Props, ref: Ref<WebViewControl>) => { | ||||
| 	const webviewRef = useRef(null); | ||||
| 	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 { Theme } from '@joplin/lib/themes/type'; | ||||
| 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 Icon from './Icon'; | ||||
| import AccessibleView from './accessibility/AccessibleView'; | ||||
|  | ||||
| type ButtonClickListener = ()=> void; | ||||
| interface ButtonProps { | ||||
| @@ -22,6 +23,10 @@ interface ButtonProps { | ||||
|  | ||||
| 	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; | ||||
| 	contentWrapperStyle?: ViewStyle; | ||||
|  | ||||
| @@ -74,6 +79,10 @@ const IconButton = (props: ButtonProps) => { | ||||
| 		setButtonLayout({ ...layoutEvt }); | ||||
| 	}, []); | ||||
|  | ||||
| 	const { onTouchStart, onTouchMove, onTouchEnd } = usePreventKeyboardDismissTouchListeners( | ||||
| 		props.preventKeyboardDismiss, props.onPress, props.disabled, | ||||
| 	); | ||||
|  | ||||
| 	const button = ( | ||||
| 		<Pressable | ||||
| 			onPress={props.onPress} | ||||
| @@ -81,6 +90,10 @@ const IconButton = (props: ButtonProps) => { | ||||
| 			onPressIn={onPressIn} | ||||
| 			onPressOut={onPressOut} | ||||
|  | ||||
| 			onTouchStart={onTouchStart} | ||||
| 			onTouchMove={onTouchMove} | ||||
| 			onTouchEnd={onTouchEnd} | ||||
|  | ||||
| 			style={ props.containerStyle } | ||||
|  | ||||
| 			disabled={ props.disabled ?? false } | ||||
| @@ -108,14 +121,11 @@ const IconButton = (props: ButtonProps) => { | ||||
| 		if (!props.description) return null; | ||||
|  | ||||
| 		return ( | ||||
| 			<View | ||||
| 			<AccessibleView | ||||
| 				// Any information given by the tooltip should also be provided via | ||||
| 				// [accessibilityLabel]/[accessibilityHint]. As such, we can hide the tooltip | ||||
| 				// from the screen reader. | ||||
| 				// On Android: | ||||
| 				importantForAccessibility='no-hide-descendants' | ||||
| 				// On iOS: | ||||
| 				accessibilityElementsHidden={true} | ||||
| 				inert={true} | ||||
|  | ||||
| 				// Position the menu beneath the button so the tooltip appears in the | ||||
| 				// correct location. | ||||
| @@ -150,7 +160,7 @@ const IconButton = (props: ButtonProps) => { | ||||
| 						</Text> | ||||
| 					</MenuOptions> | ||||
| 				</Menu> | ||||
| 			</View> | ||||
| 			</AccessibleView> | ||||
| 		); | ||||
| 	}; | ||||
|  | ||||
| @@ -181,4 +191,40 @@ const useTooltipStyles = (themeId: number) => { | ||||
| 	}, [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; | ||||
|   | ||||
| @@ -27,6 +27,7 @@ const useStyles = (backgroundColor?: string) => { | ||||
| 				...backgroundPadding, | ||||
| 				backgroundColor, | ||||
| 				flexGrow: 1, | ||||
| 				flexShrink: 1, | ||||
| 			}, | ||||
| 		}); | ||||
| 	}, [isLandscape, backgroundColor]); | ||||
|   | ||||
| @@ -14,12 +14,13 @@ import { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnM | ||||
| import Resource from '@joplin/lib/models/Resource'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import { ResourceInfo } from './hooks/useRerenderHandler'; | ||||
| import getWebViewDomById from '../../utils/testing/getWebViewDomById'; | ||||
|  | ||||
| interface WrapperProps { | ||||
| 	noteBody: string; | ||||
| 	highlightedKeywords?: string[]; | ||||
| 	noteResources?: unknown; | ||||
| 	noteResources?: Record<string, ResourceInfo>; | ||||
| 	onJoplinLinkClick?: HandleMessageCallback; | ||||
| 	onScroll?: (percent: number)=> void; | ||||
| 	onMarkForDownload?: OnMarkForDownloadCallback; | ||||
|   | ||||
| @@ -4,11 +4,12 @@ import useOnMessage, { HandleMessageCallback, OnMarkForDownloadCallback } from ' | ||||
| import { useRef, useCallback, useState, useMemo } from 'react'; | ||||
| import { View, ViewStyle } from 'react-native'; | ||||
| import BackButtonDialogBox from '../BackButtonDialogBox'; | ||||
| import ExtendedWebView, { WebViewControl } from '../ExtendedWebView'; | ||||
| import ExtendedWebView from '../ExtendedWebView'; | ||||
| import { WebViewControl } from '../ExtendedWebView/types'; | ||||
| import useOnResourceLongPress from './hooks/useOnResourceLongPress'; | ||||
| import useRenderer from './hooks/useRenderer'; | ||||
| import { OnWebViewMessageHandler } from './types'; | ||||
| import useRerenderHandler from './hooks/useRerenderHandler'; | ||||
| import useRerenderHandler, { ResourceInfo } from './hooks/useRerenderHandler'; | ||||
| import useSource from './hooks/useSource'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import uuid from '@joplin/lib/uuid'; | ||||
| @@ -22,8 +23,7 @@ interface Props { | ||||
| 	noteBody: string; | ||||
| 	noteMarkupLanguage: MarkupLanguage; | ||||
| 	highlightedKeywords: string[]; | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	noteResources: any; | ||||
| 	noteResources: Record<string, ResourceInfo>; | ||||
| 	paddingBottom: number; | ||||
| 	initialScroll: number|null; | ||||
| 	noteHash: string; | ||||
|   | ||||
| @@ -12,6 +12,7 @@ const defaultRendererSettings: RendererSettings = { | ||||
| 	codeTheme: 'atom-one-light.css', | ||||
| 	noteHash: '', | ||||
| 	initialScroll: 0, | ||||
| 	readAssetBlob: async (_path: string)=>new Blob(), | ||||
|  | ||||
| 	createEditPopupSyntax: '', | ||||
| 	destroyEditPopupSyntax: '', | ||||
| @@ -28,6 +29,7 @@ const makeRenderer = (options: Partial<RendererSetupOptions>) => { | ||||
| 			resourceDir: Setting.value('resourceDir'), | ||||
| 			resourceDownloadMode: 'auto', | ||||
| 		}, | ||||
| 		useTransferredFiles: false, | ||||
| 		fsDriver: shim.fsDriver(), | ||||
| 		pluginOptions: {}, | ||||
| 	}; | ||||
|   | ||||
| @@ -12,6 +12,11 @@ export interface RendererSetupOptions { | ||||
| 		resourceDir: 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; | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	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 | ||||
| 	pluginSettings: Record<string, any>; | ||||
| 	requestPluginSetting: (pluginId: string, settingKey: string)=> void; | ||||
| 	readAssetBlob: (assetPath: string)=> Promise<Blob>; | ||||
| } | ||||
|  | ||||
| export interface MarkupRecord { | ||||
| @@ -45,6 +51,7 @@ export default class Renderer { | ||||
| 	private lastSettings: RendererSettings|null = null; | ||||
| 	private extraContentScripts: ExtraContentScript[] = []; | ||||
| 	private lastRenderMarkup: MarkupRecord|null = null; | ||||
| 	private resourcePathOverrides: Record<string, string> = Object.create(null); | ||||
|  | ||||
| 	public constructor(private setupOptions: RendererSetupOptions) { | ||||
| 		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( | ||||
| 		extraContentScripts: ExtraContentScriptSource[], | ||||
| 	) { | ||||
| @@ -108,6 +127,7 @@ export default class Renderer { | ||||
| 			editPopupFiletypes: ['image/svg+xml'], | ||||
| 			createEditPopupSyntax: settings.createEditPopupSyntax, | ||||
| 			destroyEditPopupSyntax: settings.destroyEditPopupSyntax, | ||||
| 			itemIdToUrl: this.setupOptions.useTransferredFiles ? (id: string) => this.getResourcePathOverride(id) : undefined, | ||||
|  | ||||
| 			settingValue: (pluginId: string, settingName: string) => { | ||||
| 				const settingKey = `${pluginId}.${settingName}`; | ||||
| @@ -151,7 +171,17 @@ export default class Renderer { | ||||
| 		} | ||||
|  | ||||
| 		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); | ||||
| 	} | ||||
| @@ -187,9 +217,6 @@ export default class Renderer { | ||||
| 				} | ||||
| 			} | ||||
| 		}, 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) { | ||||
|   | ||||
| @@ -8,6 +8,7 @@ export interface RendererWebViewOptions { | ||||
| 		resourceDir: string; | ||||
| 		resourceDownloadMode: string; | ||||
| 	}; | ||||
| 	useTransferredFiles: boolean; | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	pluginOptions: Record<string, any>; | ||||
| } | ||||
|   | ||||
| @@ -1,21 +1,79 @@ | ||||
| import { RenderResultPluginAsset } from '@joplin/renderer/types'; | ||||
| import { join, dirname } from 'path'; | ||||
|  | ||||
| type PluginAssetRecord = { | ||||
| 	element: HTMLElement; | ||||
| }; | ||||
| 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 | ||||
| // add the same CSS files multiple times. | ||||
| // | ||||
| // Shared with app-desktop/gui-note-viewer. | ||||
| // | ||||
| // TODO: If possible, refactor such that this function is not duplicated. | ||||
| const addPluginAssets = (assets: RenderResultPluginAsset[]) => { | ||||
| const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Options) => { | ||||
| 	if (!assets) return; | ||||
|  | ||||
| 	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 = []; | ||||
|  | ||||
| 	for (let i = 0; i < assets.length; i++) { | ||||
| @@ -34,14 +92,33 @@ const addPluginAssets = (assets: RenderResultPluginAsset[]) => { | ||||
|  | ||||
| 		let element = null; | ||||
|  | ||||
| 		if (options.inlineAssets) { | ||||
| 			if (asset.mime === 'application/javascript') { | ||||
| 				element = document.createElement('script'); | ||||
| 			} else if (asset.mime === 'text/css') { | ||||
| 				element = document.createElement('style'); | ||||
| 			} | ||||
|  | ||||
| 			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; | ||||
| 			pluginAssetsContainer.appendChild(element); | ||||
| 			} else if (asset.mime === 'text/css') { | ||||
| 				element = document.createElement('link'); | ||||
| 				element.rel = 'stylesheet'; | ||||
| 				element.href = encodedPath; | ||||
| 			} | ||||
| 		} | ||||
| 		if (element) { | ||||
| 			pluginAssetsContainer.appendChild(element); | ||||
| 		} | ||||
|  | ||||
|   | ||||
| @@ -5,11 +5,22 @@ import { Theme } from '@joplin/lib/themes/type'; | ||||
| import { useMemo } from 'react'; | ||||
| import { extname } from 'path'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { Platform } from 'react-native'; | ||||
| const Icon = require('react-native-vector-icons/Ionicons').default; | ||||
|  | ||||
| export const editPopupClass = 'joplin-editPopup'; | ||||
|  | ||||
| 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; | ||||
|  | ||||
| 	// Copy to a location that can be read within a WebView | ||||
|   | ||||
| @@ -1,13 +1,15 @@ | ||||
| import { useCallback } from 'react'; | ||||
|  | ||||
| const { ToastAndroid } = require('react-native'); | ||||
| const { _ } = require('@joplin/lib/locale.js'); | ||||
| import { reg } from '@joplin/lib/registry'; | ||||
| const { dialogs } = require('../../../utils/dialogs.js'); | ||||
| import Resource from '@joplin/lib/models/Resource'; | ||||
| import { copyToCache } from '../../../utils/ShareUtils'; | ||||
| 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 { | ||||
| 	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 | ||||
| 			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; | ||||
| 			} | ||||
|  | ||||
| @@ -46,19 +48,13 @@ export default function useOnResourceLongPress(callbacks: Callbacks, dialogBoxRe | ||||
| 				onJoplinLinkClick(`joplin://${resourceId}`); | ||||
| 			} else if (action === 'share') { | ||||
| 				const fileToShare = await copyToCache(resource); | ||||
|  | ||||
| 				await Share.open({ | ||||
| 					type: resource.mime, | ||||
| 					filename: resource.title, | ||||
| 					url: `file://${fileToShare}`, | ||||
| 					failOnCancel: false, | ||||
| 				}); | ||||
| 				await shareFile(fileToShare, resource.mime); | ||||
| 			} else if (action === 'edit') { | ||||
| 				onRequestEditResource(`edit:${resourceId}`); | ||||
| 			} | ||||
| 		} catch (e) { | ||||
| 			reg.logger().error('Could not handle link long press', e); | ||||
| 			ToastAndroid.show('An error occurred, check log for details', ToastAndroid.SHORT); | ||||
| 			logger.error('Could not handle link long press', e); | ||||
| 			void shim.showMessageBox(`An error occurred, check log for details: ${e}`); | ||||
| 		} | ||||
| 	}, [onJoplinLinkClick, onRequestEditResource, dialogBoxRef]); | ||||
| } | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { Dispatch, RefObject, SetStateAction, useEffect, useMemo, useRef } from 'react'; | ||||
| import { WebViewControl } from '../../ExtendedWebView'; | ||||
| import { WebViewControl } from '../../ExtendedWebView/types'; | ||||
| import { OnScrollCallback, OnWebViewMessageHandler } from '../types'; | ||||
| import RNToWebViewMessenger from '../../../utils/ipc/RNToWebViewMessenger'; | ||||
| import { NoteViewerLocalApi, NoteViewerRemoteApi } from '../bundledJs/types'; | ||||
|   | ||||
| @@ -8,6 +8,15 @@ import { useEffect, useState } from 'react'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import { ExtraContentScriptSource } from '../bundledJs/types'; | ||||
| 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 { | ||||
| 	renderer: Renderer; | ||||
| @@ -17,7 +26,7 @@ interface Props { | ||||
| 	themeId: number; | ||||
|  | ||||
| 	highlightedKeywords: string[]; | ||||
| 	noteResources: string[]; | ||||
| 	noteResources: Record<string, ResourceInfo>; | ||||
| 	noteHash: string; | ||||
| 	initialScroll: number|undefined; | ||||
|  | ||||
| @@ -113,6 +122,25 @@ const useRerenderHandler = (props: Props) => { | ||||
| 		} | ||||
| 		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 config = { | ||||
| 			// We .stringify the theme to avoid a JSON serialization error involving | ||||
| @@ -150,6 +178,11 @@ const useRerenderHandler = (props: Props) => { | ||||
| 					setPluginSettingKeys(newPluginSettingKeys); | ||||
| 				} | ||||
| 			}, | ||||
| 			readAssetBlob: (assetPath: string) => { | ||||
| 				const assetsDir = `${Setting.value('resourceDir')}/`; | ||||
| 				const path = shim.fsDriver().resolveRelativePathWithinDir(assetsDir, assetPath); | ||||
| 				return shim.fsDriver().fileAtPath(path); | ||||
| 			}, | ||||
|  | ||||
| 			createEditPopupSyntax, | ||||
| 			destroyEditPopupSyntax, | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import shim from '@joplin/lib/shim'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import { RendererWebViewOptions } from '../bundledJs/types'; | ||||
| import { themeStyle } from '../../global-style'; | ||||
| import { Platform } from 'react-native'; | ||||
|  | ||||
| const useSource = (tempDirPath: string, themeId: number) => { | ||||
| 	const injectedJs = useMemo(() => { | ||||
| @@ -20,6 +21,9 @@ const useSource = (tempDirPath: string, themeId: number) => { | ||||
| 				resourceDir: Setting.value('resourceDir'), | ||||
| 				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, | ||||
| 		}; | ||||
|  | ||||
|   | ||||
| @@ -5,12 +5,14 @@ import Setting from '@joplin/lib/models/Setting'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { themeStyle } from '@joplin/lib/theme'; | ||||
| import { Theme } from '@joplin/lib/themes/type'; | ||||
| import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||||
| import { Alert, BackHandler } from 'react-native'; | ||||
| import ExtendedWebView, { WebViewControl } from '../../ExtendedWebView'; | ||||
| import { MutableRefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; | ||||
| import { BackHandler, Platform } from 'react-native'; | ||||
| import ExtendedWebView from '../../ExtendedWebView'; | ||||
| import { WebViewControl } from '../../ExtendedWebView/types'; | ||||
| import { clearAutosave, writeAutosave } from './autosave'; | ||||
| import { LocalizedStrings } from './js-draw/types'; | ||||
| import VersionInfo from 'react-native-version-info'; | ||||
| import { DialogContext } from '../../DialogManager'; | ||||
| import { OnMessageEvent } from '../../ExtendedWebView/types'; | ||||
|  | ||||
|  | ||||
| @@ -85,6 +87,8 @@ const ImageEditor = (props: Props) => { | ||||
| 	const webviewRef: MutableRefObject<WebViewControl>|null = useRef(null); | ||||
| 	const [imageChanged, setImageChanged] = useState(false); | ||||
|  | ||||
| 	const dialogs = useContext(DialogContext); | ||||
|  | ||||
| 	const onRequestCloseEditor = useCallback((promptIfUnsaved: boolean) => { | ||||
| 		const discardChangesAndClose = async () => { | ||||
| 			await clearAutosave(); | ||||
| @@ -96,7 +100,7 @@ const ImageEditor = (props: Props) => { | ||||
| 			return true; | ||||
| 		} | ||||
|  | ||||
| 		Alert.alert( | ||||
| 		dialogs.prompt( | ||||
| 			_('Save changes?'), _('This drawing may have unsaved changes.'), [ | ||||
| 				{ | ||||
| 					text: _('Discard changes'), | ||||
| @@ -114,7 +118,7 @@ const ImageEditor = (props: Props) => { | ||||
| 			], | ||||
| 		); | ||||
| 		return true; | ||||
| 	}, [webviewRef, props.onExit, imageChanged]); | ||||
| 	}, [webviewRef, dialogs, props.onExit, imageChanged]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		const hardwareBackPressListener = () => { | ||||
| @@ -268,14 +272,28 @@ const ImageEditor = (props: Props) => { | ||||
| 	}, [css]); | ||||
|  | ||||
| 	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. | ||||
| 		// Thus, do so after the main content has been loaded. | ||||
| 		webviewRef.current.injectJS(`(async () => { | ||||
| 			if (window.editorControl) { | ||||
| 				const initialSVGPath = ${JSON.stringify(props.resourceFilename)}; | ||||
| 				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]); | ||||
|   | ||||
| @@ -58,7 +58,7 @@ describe('createJsDrawEditor', () => { | ||||
| 		}); | ||||
|  | ||||
| 		// Load no image and an empty template so that autosave can start | ||||
| 		await editorControl.loadImageOrTemplate('', '{}'); | ||||
| 		await editorControl.loadImageOrTemplate('', '{}', undefined); | ||||
|  | ||||
| 		expect(calledAutosaveCount).toBe(0); | ||||
|  | ||||
|   | ||||
| @@ -149,11 +149,13 @@ export const createJsDrawEditor = ( | ||||
|  | ||||
| 	const editorControl = { | ||||
| 		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. | ||||
| 			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 | ||||
| 			if (svgData === '') { | ||||
|   | ||||
| @@ -92,6 +92,16 @@ const Toolbar: React.FC<ToolbarProps> = (props: ToolbarProps) => { | ||||
| 		</View> | ||||
| 	); | ||||
|  | ||||
| 	const overflow = ( | ||||
| 		<ScrollView style={{ flex: 1 }}> | ||||
| 			<ToolbarOverflowRows | ||||
| 				buttonGroups={props.buttons} | ||||
| 				styleSheet={props.styleSheet} | ||||
| 				onToggleOverflow={onToggleOverflowVisible} | ||||
| 			/> | ||||
| 		</ScrollView> | ||||
| 	); | ||||
|  | ||||
| 	return ( | ||||
| 		<View | ||||
| 			style={{ | ||||
| @@ -106,14 +116,7 @@ const Toolbar: React.FC<ToolbarProps> = (props: ToolbarProps) => { | ||||
| 			}} | ||||
| 			onLayout={onContainerLayout} | ||||
| 		> | ||||
| 			<ScrollView> | ||||
| 				<ToolbarOverflowRows | ||||
| 					buttonGroups={props.buttons} | ||||
| 					styleSheet={props.styleSheet} | ||||
| 					visible={overflowButtonsVisible} | ||||
| 					onToggleOverflow={onToggleOverflowVisible} | ||||
| 				/> | ||||
| 			</ScrollView> | ||||
| 			{ overflowButtonsVisible ? overflow : null } | ||||
| 			{ !overflowButtonsVisible ? mainButtonRow : null } | ||||
| 		</View> | ||||
| 	); | ||||
|   | ||||
| @@ -63,6 +63,7 @@ const ToolbarButton = ({ styleSheet, spec, onActionComplete, style }: ToolbarBut | ||||
| 			onPress={onPress} | ||||
| 			description={ spec.description } | ||||
| 			disabled={ disabled } | ||||
| 			preventKeyboardDismiss={true} | ||||
|  | ||||
| 			iconName={spec.icon} | ||||
| 			iconStyle={styles.iconStyle} | ||||
|   | ||||
| @@ -11,7 +11,6 @@ type OnToggleOverflowCallback = ()=> void; | ||||
| interface OverflowPopupProps { | ||||
| 	buttonGroups: ButtonGroup[]; | ||||
| 	styleSheet: StyleSheetData; | ||||
| 	visible: boolean; | ||||
|  | ||||
| 	// Should be created using useCallback | ||||
| 	onToggleOverflow: OnToggleOverflowCallback; | ||||
| @@ -117,16 +116,13 @@ const ToolbarOverflowRows: React.FC<OverflowPopupProps> = (props: OverflowPopupP | ||||
| 		/> | ||||
| 	); | ||||
|  | ||||
| 	if (!props.visible) { | ||||
| 		return null; | ||||
| 	} | ||||
| 	return ( | ||||
| 		<View | ||||
| 			style={{ | ||||
| 				height: props.buttonGroups.length * buttonSize, | ||||
| 				flexDirection: 'column', | ||||
| 				flexGrow: 1, | ||||
| 				display: !props.visible ? 'none' : 'flex', | ||||
| 				display: 'flex', | ||||
| 			}} | ||||
| 			onLayout={onContainerLayout} | ||||
| 		> | ||||
|   | ||||
| @@ -4,7 +4,8 @@ import { themeStyle } from '@joplin/lib/theme'; | ||||
| import themeToCss from '@joplin/lib/services/style/themeToCss'; | ||||
| import EditLinkDialog from './EditLinkDialog'; | ||||
| 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 { forwardRef, RefObject, useEffect, useImperativeHandle } from 'react'; | ||||
| @@ -71,9 +72,8 @@ function useCss(themeId: number): string { | ||||
| 			body { | ||||
| 				margin: 0; | ||||
| 				height: 100vh; | ||||
| 				width: 100vh; | ||||
| 				width: 100vw; | ||||
| 				min-width: 100vw; | ||||
| 				/* Prefer 100% -- 100vw shows an unnecessary horizontal scrollbar in Google Chrome (desktop). */ | ||||
| 				width: 100%; | ||||
| 				box-sizing: border-box; | ||||
|  | ||||
| 				padding-left: 1px; | ||||
| @@ -83,6 +83,44 @@ function useCss(themeId: number): string { | ||||
|  | ||||
| 				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]); | ||||
| } | ||||
| @@ -469,7 +507,7 @@ function NoteEditor(props: Props, ref: any) { | ||||
| 	const onMessage = useCallback((event: OnMessageEvent) => { | ||||
| 		const data = event.nativeEvent.data; | ||||
|  | ||||
| 		if (data.indexOf('error:') === 0) { | ||||
| 		if (typeof data === 'string' && data.indexOf('error:') === 0) { | ||||
| 			logger.error('CodeMirror error', data); | ||||
| 			return; | ||||
| 		} | ||||
|   | ||||
| @@ -1,16 +1,17 @@ | ||||
| const React = require('react'); | ||||
| import { useCallback, useMemo, useState } from 'react'; | ||||
| import { useCallback, useContext, useMemo, useState } from 'react'; | ||||
| const { View, FlatList, StyleSheet } = require('react-native'); | ||||
| import createRootStyle from '../../utils/createRootStyle'; | ||||
| import ScreenHeader from '../ScreenHeader'; | ||||
| const { FAB, List } = require('react-native-paper'); | ||||
| import { Profile } from '@joplin/lib/services/profileConfig/types'; | ||||
| import useProfileConfig from './useProfileConfig'; | ||||
| import { Alert } from 'react-native'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { deleteProfileById } from '@joplin/lib/services/profileConfig'; | ||||
| import { saveProfileConfig, switchProfile } from '../../services/profiles'; | ||||
| import { themeStyle } from '../global-style'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { DialogContext } from '../DialogManager'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| @@ -48,18 +49,26 @@ export default (props: Props) => { | ||||
| 		return profileConfig ? profileConfig.profiles : []; | ||||
| 	}, [profileConfig]); | ||||
|  | ||||
| 	const dialogs = useContext(DialogContext); | ||||
|  | ||||
| 	const onProfileItemPress = useCallback(async (profile: Profile) => { | ||||
| 		const doIt = async () => { | ||||
| 			try { | ||||
| 				await switchProfile(profile.id); | ||||
| 			} 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.'); | ||||
| 		if (shim.mobilePlatform() === 'web') { | ||||
| 			if (confirm(switchProfileMessage)) { | ||||
| 				void doIt(); | ||||
| 			} | ||||
| 		} else { | ||||
| 			dialogs.prompt( | ||||
| 				_('Confirmation'), | ||||
| 			_('To switch the profile, the app is going to close and you will need to restart it.'), | ||||
| 				switchProfileMessage, | ||||
| 				[ | ||||
| 					{ | ||||
| 						text: _('Continue'), | ||||
| @@ -73,7 +82,8 @@ export default (props: Props) => { | ||||
| 					}, | ||||
| 				], | ||||
| 			); | ||||
| 	}, []); | ||||
| 		} | ||||
| 	}, [dialogs]); | ||||
|  | ||||
| 	const onEditProfile = useCallback(async (profileId: string) => { | ||||
| 		props.dispatch({ | ||||
| @@ -90,11 +100,11 @@ export default (props: Props) => { | ||||
| 				await saveProfileConfig(newConfig); | ||||
| 				setProfileConfigTime(Date.now()); | ||||
| 			} catch (error) { | ||||
| 				Alert.alert(error.message); | ||||
| 				dialogs.prompt(_('Error'), error.message); | ||||
| 			} | ||||
| 		}; | ||||
|  | ||||
| 		Alert.alert( | ||||
| 		dialogs.prompt( | ||||
| 			_('Delete this profile?'), | ||||
| 			_('All data, including notes, notebooks and tags will be permanently deleted.'), | ||||
| 			[ | ||||
| @@ -110,23 +120,15 @@ export default (props: Props) => { | ||||
| 				}, | ||||
| 			], | ||||
| 		); | ||||
| 	}, [profileConfig]); | ||||
| 	}, [dialogs, profileConfig]); | ||||
|  | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	const renderProfileItem = (event: any) => { | ||||
| 		const profile = event.item as Profile; | ||||
| 		const titleStyle = { fontWeight: profile.id === profileConfig.currentProfileId ? 'bold' : 'normal' }; | ||||
| 		return ( | ||||
| 			<List.Item | ||||
| 				title={profile.name} | ||||
| 				style={style.profileListItem} | ||||
| 				titleStyle={titleStyle} | ||||
| 				left={() => <List.Icon icon="file-account-outline" />} | ||||
| 				key={profile.id} | ||||
| 				profileId={profile.id} | ||||
| 				onPress={() => { void onProfileItemPress(profile); }} | ||||
| 				onLongPress={() => { | ||||
| 					Alert.alert( | ||||
| 		const onConfigure = (event: Event) => { | ||||
| 			event.preventDefault(); | ||||
|  | ||||
| 			dialogs.prompt( | ||||
| 				_('Configuration'), | ||||
| 				'', | ||||
| 				[ | ||||
| @@ -147,7 +149,20 @@ export default (props: Props) => { | ||||
| 					}, | ||||
| 				], | ||||
| 			); | ||||
| 				}} | ||||
| 		}; | ||||
|  | ||||
| 		const titleStyle = { fontWeight: profile.id === profileConfig.currentProfileId ? 'bold' : 'normal' }; | ||||
| 		return ( | ||||
| 			<List.Item | ||||
| 				title={profile.name} | ||||
| 				style={style.profileListItem} | ||||
| 				titleStyle={titleStyle} | ||||
| 				left={() => <List.Icon icon="file-account-outline" />} | ||||
| 				key={profile.id} | ||||
| 				profileId={profile.id} | ||||
| 				onPress={() => { void onProfileItemPress(profile); }} | ||||
| 				onLongPress={onConfigure} | ||||
| 				onContextMenu={onConfigure} | ||||
| 			/> | ||||
| 		); | ||||
| 	}; | ||||
|   | ||||
| @@ -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 { PureComponent, ReactElement } from 'react'; | ||||
| 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 { BackButtonService } = require('../../services/back-button.js'); | ||||
| 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 { Dispatch } from 'redux'; | ||||
| import WarningBanner from './WarningBanner'; | ||||
| import WebBetaButton from './WebBetaButton'; | ||||
|  | ||||
| // Rather than applying a padding to the whole bar, it is applied to each | ||||
| // individual component (button, picker, etc.) so that the touchable areas | ||||
| @@ -112,7 +113,6 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade | ||||
| 			container: { | ||||
| 				flexDirection: 'column', | ||||
| 				backgroundColor: theme.backgroundColor2, | ||||
| 				alignItems: 'center', | ||||
| 				shadowColor: '#000000', | ||||
| 				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 | ||||
| 		function deleteButton(styles: any, onPress: OnPressCallback, disabled: boolean) { | ||||
| 			return ( | ||||
| @@ -633,6 +645,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade | ||||
| 		const sideMenuComp = !showSideMenuButton ? null : sideMenuButton(this.styles(), () => this.sideMenuButton_press()); | ||||
| 		const backButtonComp = !showBackButton ? null : backButton(this.styles(), () => this.backButton_press(), backButtonDisabled); | ||||
| 		const pluginPanelsComp = pluginPanelToggleButton(this.styles(), () => this.pluginPanelToggleButton_press()); | ||||
| 		const betaIconComp = betaIconButton(); | ||||
| 		const selectAllButtonComp = !showSelectAllButton ? null : selectAllButton(this.styles(), () => this.selectAllButton_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; | ||||
| @@ -642,7 +655,10 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade | ||||
|  | ||||
| 		// To allow the notebook dropdown (and perhaps other components) to have sufficient | ||||
| 		// space while in use, we allow certain buttons to be hidden. | ||||
| 		const hideableRightComponents = pluginPanelsComp; | ||||
| 		const hideableRightComponents = <> | ||||
| 			{pluginPanelsComp} | ||||
| 			{betaIconComp} | ||||
| 		</>; | ||||
|  | ||||
| 		const titleComp = createTitleComponent(headerItemDisabled, hideableRightComponents); | ||||
| 		const windowHeight = Dimensions.get('window').height - 50; | ||||
|   | ||||
| @@ -1,8 +1,11 @@ | ||||
| import * as React from 'react'; | ||||
| import { themeStyle } from './global-style'; | ||||
| 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 { Platform } from 'react-native'; | ||||
| import Modal from './Modal'; | ||||
| import { formatMsToLocal } from '@joplin/utils/time'; | ||||
| const DateTimePickerModal = require('react-native-modal-datetime-picker').default; | ||||
|  | ||||
| const styles = StyleSheet.create({ | ||||
| @@ -10,7 +13,6 @@ const styles = StyleSheet.create({ | ||||
| 		flex: 1, | ||||
| 		justifyContent: 'center', | ||||
| 		alignItems: 'center', | ||||
| 		marginTop: 22, | ||||
| 	}, | ||||
| 	modalView: { | ||||
| 		display: 'flex', | ||||
| @@ -100,9 +102,26 @@ export default class SelectDateTimeDialog extends React.PureComponent<any, any> | ||||
| 		this.setState({ showPicker: true }); | ||||
| 	} | ||||
|  | ||||
| 	// web | ||||
| 	private onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||
| 		this.setState({ date: new Date(event.target.value) }); | ||||
| 	}; | ||||
|  | ||||
| 	public renderContent() { | ||||
| 		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 ( | ||||
| 			<View style={{ flex: 0, margin: 20, alignItems: 'center' }}> | ||||
| 				<View style={{ flexDirection: 'row', alignItems: 'center' }}> | ||||
| @@ -129,22 +148,20 @@ export default class SelectDateTimeDialog extends React.PureComponent<any, any> | ||||
| 		const theme = themeStyle(this.props.themeId); | ||||
|  | ||||
| 		return ( | ||||
| 			<View style={styles.centeredView}> | ||||
| 			<Modal | ||||
|  | ||||
| 				transparent={true} | ||||
| 				visible={modalVisible} | ||||
| 				containerStyle={styles.centeredView} | ||||
| 				onRequestClose={() => { | ||||
| 					this.onReject(); | ||||
| 				}} | ||||
| 			> | ||||
| 					<View style={styles.centeredView}> | ||||
| 				<View style={{ ...styles.modalView, backgroundColor: theme.backgroundColor }}> | ||||
| 							<View style={{ padding: 15, paddingBottom: 0, flex: 0, width: '100%', borderBottomWidth: 1, borderBottomColor: theme.dividerColor, borderBottomStyle: 'solid' }}> | ||||
| 					<View style={{ padding: 15, flexBasis: 'auto', paddingBottom: 0, flexGrow: 0, width: '100%', borderBottomWidth: 1, borderBottomColor: theme.dividerColor, borderBottomStyle: 'solid' }}> | ||||
| 						<Text style={{ ...styles.modalText, color: theme.color, fontSize: 14, fontWeight: 'bold' }}>{_('Set alarm')}</Text> | ||||
| 					</View> | ||||
| 					{this.renderContent()} | ||||
| 							<View style={{ padding: 20, borderTopWidth: 1, borderTopStyle: 'solid', borderTopColor: theme.dividerColor }}> | ||||
| 					<View style={{ padding: 20, flexBasis: 'auto', borderTopWidth: 1, borderTopStyle: 'solid', borderTopColor: theme.dividerColor }}> | ||||
| 						<View style={{ marginBottom: 10 }}> | ||||
| 							<Button title={_('Save alarm')} onPress={() => this.onAccept()} key="saveButton" /> | ||||
| 						</View> | ||||
| @@ -156,9 +173,7 @@ export default class SelectDateTimeDialog extends React.PureComponent<any, any> | ||||
| 						</View> | ||||
| 					</View> | ||||
| 				</View> | ||||
| 					</View> | ||||
| 			</Modal> | ||||
| 			</View> | ||||
| 		); | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -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 { _ } from '@joplin/lib/locale'; | ||||
| import { Dispatch } from 'redux'; | ||||
| import { useWindowDimensions } from 'react-native'; | ||||
| import { Platform, useWindowDimensions, View } from 'react-native'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import AccessibleWebMenu from '../accessibility/AccessibleModalMenu'; | ||||
| 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
 | ||||
| @@ -40,7 +41,7 @@ const useIcon = (iconName: string) => { | ||||
| 	}, [iconName]); | ||||
| }; | ||||
| 
 | ||||
| const ActionButton = (props: ActionButtonProps) => { | ||||
| const FloatingActionButton = (props: ActionButtonProps) => { | ||||
| 	const [open, setOpen] = useState(false); | ||||
| 	const onMenuToggled: FABGroupProps['onStateChange'] = useCallback(state => { | ||||
| 		props.dispatch({ | ||||
| @@ -75,11 +76,22 @@ const ActionButton = (props: ActionButtonProps) => { | ||||
| 	const marginTop = adjustMargins ? Math.max(0, windowSize.height - 140) : undefined; | ||||
| 	const marginStart = adjustMargins ? Math.max(0, windowSize.width - 200) : undefined; | ||||
| 
 | ||||
| 	return ( | ||||
| 		<Portal> | ||||
| 			<FAB.Group | ||||
| 	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={props.mainButton?.label ?? _('Add new')} | ||||
| 		accessibilityLabel={label} | ||||
| 		style={{ marginStart, marginTop }} | ||||
| 		icon={ open ? openIcon : closedIcon } | ||||
| 		fabStyle={{ | ||||
| @@ -89,9 +101,22 @@ const ActionButton = (props: ActionButtonProps) => { | ||||
| 		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 ( | ||||
| 		<Portal> | ||||
| 			{mainMenu} | ||||
| 			{accessibleMenu} | ||||
| 		</Portal> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| export default ActionButton; | ||||
| export default FloatingActionButton; | ||||
| @@ -1,7 +1,7 @@ | ||||
| import BasePluginRunner from '@joplin/lib/services/plugins/BasePluginRunner'; | ||||
| import PluginApiGlobal from '@joplin/lib/services/plugins/api/Global'; | ||||
| import Plugin from '@joplin/lib/services/plugins/Plugin'; | ||||
| import { WebViewControl } from '../ExtendedWebView'; | ||||
| import { WebViewControl } from '../ExtendedWebView/types'; | ||||
| import { RefObject } from 'react'; | ||||
| import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger'; | ||||
| import { PluginMainProcessApi, PluginWebViewApi } from './types'; | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| 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 shim from '@joplin/lib/shim'; | ||||
| import PluginRunner from './PluginRunner'; | ||||
| import loadPlugins from '@joplin/lib/services/plugins/loadPlugins'; | ||||
| import { connect, useStore } from 'react-redux'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import { View } from 'react-native'; | ||||
| import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import { PluginHtmlContents, PluginStates } from '@joplin/lib/services/plugins/reducer'; | ||||
| import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; | ||||
| @@ -14,6 +14,7 @@ import PluginDialogManager from './dialogs/PluginDialogManager'; | ||||
| import { AppState } from '../../utils/types'; | ||||
| import usePrevious from '@joplin/lib/hooks/usePrevious'; | ||||
| import PlatformImplementation from '../../services/plugins/PlatformImplementation'; | ||||
| import AccessibleView from '../accessibility/AccessibleView'; | ||||
|  | ||||
| const logger = Logger.create('PluginRunnerWebView'); | ||||
|  | ||||
| @@ -172,9 +173,9 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => { | ||||
| 	}; | ||||
|  | ||||
| 	return ( | ||||
| 		<View style={{ display: 'none' }}> | ||||
| 		<AccessibleView style={{ display: 'none' }} inert={true}> | ||||
| 			{renderWebView()} | ||||
| 		</View> | ||||
| 		</AccessibleView> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -47,6 +47,22 @@ const initializeDialogWebView = (messageChannelId: string) => { | ||||
| 		includeJsFiles: async (paths: string[]) => { | ||||
| 			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 () => { | ||||
| 			return getFormData(); | ||||
| 		}, | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger'; | ||||
| import { PluginMainProcessApi, PluginWebViewApi } from '../types'; | ||||
| import WebViewToRNMessenger from '../../../utils/ipc/WebViewToRNMessenger'; | ||||
| import WindowMessenger from '@joplin/lib/utils/ipc/WindowMessenger'; | ||||
| import makeSandboxedIframe from './utils/makeSandboxedIframe'; | ||||
| import makeSandboxedIframe from '@joplin/lib/utils/dom/makeSandboxedIframe'; | ||||
|  | ||||
| type PluginRecord = { | ||||
| 	iframe: HTMLIFrameElement; | ||||
| @@ -50,7 +50,7 @@ export const runPlugin = ( | ||||
| 			${pluginScript} | ||||
| 		})(); | ||||
| 	`; | ||||
| 	const backgroundIframe = makeSandboxedIframe(bodyHtml, [initialJavaScript]).iframe; | ||||
| 	const backgroundIframe = makeSandboxedIframe({ bodyHtml, headHtml: '', scripts: [initialJavaScript] }).iframe; | ||||
|  | ||||
| 	loadedPlugins[pluginId] = { | ||||
| 		iframe: backgroundIframe, | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import * as React from 'react'; | ||||
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||||
| 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 usePlugin from '@joplin/lib/hooks/usePlugin'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| @@ -46,6 +47,7 @@ const PluginUserWebView = (props: Props) => { | ||||
| 			setThemeCss: messenger.remoteApi.setThemeCss, | ||||
| 			getFormData: messenger.remoteApi.getFormData, | ||||
| 			getContentSize: messenger.remoteApi.getContentSize, | ||||
| 			runScript: messenger.remoteApi.runScript, | ||||
| 		}); | ||||
| 	}, [messenger, props.setDialogControl]); | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { useMemo, RefObject } from 'react'; | ||||
| import { DialogMainProcessApi, DialogWebViewApi } from '../../types'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import { WebViewControl } from '../../../ExtendedWebView'; | ||||
| import { WebViewControl } from '../../../ExtendedWebView/types'; | ||||
| import createOnLogHander from '../../utils/createOnLogHandler'; | ||||
| import RNToWebViewMessenger from '../../../../utils/ipc/RNToWebViewMessenger'; | ||||
| import { SerializableData } from '@joplin/lib/utils/ipc/types'; | ||||
|   | ||||
| @@ -29,8 +29,16 @@ const useWebViewSetup = (props: Props) => { | ||||
| 				jsPaths.push(resolvedPath); | ||||
| 			} | ||||
| 		} | ||||
| 		if (shim.mobilePlatform() === 'web') { | ||||
| 			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]); | ||||
|  | ||||
| 	useEffect(() => { | ||||
|   | ||||
| @@ -57,6 +57,7 @@ export interface DialogWebViewApi { | ||||
| 	//       does not reload styles/scripts). | ||||
| 	includeCssFiles: (paths: string[])=> Promise<void>; | ||||
| 	includeJsFiles: (paths: string[])=> Promise<void>; | ||||
| 	runScript: (key: string, content: string)=> Promise<void>; | ||||
|  | ||||
| 	setThemeCss: (css: string)=> Promise<void>; | ||||
| 	getFormData: ()=> Promise<SerializableData>; | ||||
|   | ||||
| @@ -35,6 +35,7 @@ import SectionDescription from './SectionDescription'; | ||||
| import EnablePluginSupportPage from './plugins/EnablePluginSupportPage'; | ||||
| import getVersionInfoText from '../../../utils/getVersionInfoText'; | ||||
| import JoplinCloudConfig, { emailToNoteDescription, emailToNoteLabel } from './JoplinCloudConfig'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
|  | ||||
| interface ConfigScreenState { | ||||
| 	// 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; | ||||
| 	} | ||||
|  | ||||
| 	private promptSaveChanges(): Promise<void> { | ||||
| 		return new Promise(resolve => { | ||||
| 	private async promptSaveChanges(): Promise<void> { | ||||
| 		if (this.hasUnsavedChanges()) { | ||||
| 				const dialogTitle: string|null = null; | ||||
| 				Alert.alert( | ||||
| 					dialogTitle, | ||||
| 					_('There are unsaved changes.'), | ||||
| 					[{ | ||||
| 						text: _('Save changes'), | ||||
| 						onPress: async () => { | ||||
| 							await this.saveButton_press(); | ||||
| 							resolve(); | ||||
| 						}, | ||||
| 					}, | ||||
| 					{ | ||||
| 						text: _('Discard changes'), | ||||
| 						onPress: () => resolve(), | ||||
| 					}], | ||||
| 				); | ||||
| 			} else { | ||||
| 				resolve(); | ||||
| 			} | ||||
| 			const response = await shim.showMessageBox(_('There are unsaved changes.'), { | ||||
| 				buttons: [_('Save changes'), _('Discard changes')], | ||||
| 			}); | ||||
| 			if (response === 0) { | ||||
| 				await this.saveButton_press(); | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private handleNavigateToNewScreen = async (): Promise<boolean> => { | ||||
|   | ||||
| @@ -3,18 +3,26 @@ import * as React from 'react'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { FunctionComponent, useCallback, useEffect, useState } from 'react'; | ||||
| 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 { openDocumentTree } from '@joplin/react-native-saf-x'; | ||||
| import { UpdateSettingValueCallback } from './types'; | ||||
| 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 { | ||||
| 	styles: ConfigScreenStyles; | ||||
| 	settingMetadata: SettingItem; | ||||
| 	mode: 'read'|'readwrite'; | ||||
| 	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 [fileSystemPath, setFileSystemPath] = useState<string>(''); | ||||
|  | ||||
| @@ -25,6 +33,15 @@ const FileSystemPathSelector: FunctionComponent<Props> = props => { | ||||
| 	}, [settingId]); | ||||
|  | ||||
| 	const selectDirectoryButtonPress = useCallback(async () => { | ||||
| 		if (shim.mobilePlatform() === 'web') { | ||||
| 			// Directory picker IDs can't include certain characters. | ||||
| 			const pickerId = `setting-${settingId}`.replace(/[^a-zA-Z]/g, '_'); | ||||
| 			const handle = await self.showDirectoryPicker({ id: pickerId, mode: props.mode }); | ||||
| 			const fsDriver = shim.fsDriver() as FsDriverWeb; | ||||
| 			const uri = await fsDriver.mountExternalDirectory(handle, pickerId, props.mode); | ||||
| 			await props.updateSettingValue(settingId, uri); | ||||
| 			setFileSystemPath(uri); | ||||
| 		} else { | ||||
| 			try { | ||||
| 				const doc = await openDocumentTree(true); | ||||
| 				if (doc?.uri) { | ||||
| @@ -36,19 +53,22 @@ const FileSystemPathSelector: FunctionComponent<Props> = props => { | ||||
| 			} catch (e) { | ||||
| 				reg.logger().info('Didn\'t pick sync dir: ', e); | ||||
| 			} | ||||
| 	}, [props.updateSettingValue, settingId]); | ||||
| 		} | ||||
| 	}, [props.updateSettingValue, settingId, props.mode]); | ||||
|  | ||||
| 	// Unsupported on non-Android platforms. | ||||
| 	if (!shim.fsDriver().isUsingAndroidSAF()) { | ||||
| 	// Supported on Android and some versions of Chrome | ||||
| 	const supported = shim.fsDriver().isUsingAndroidSAF() || (shim.mobilePlatform() === 'web' && 'showDirectoryPicker' in self); | ||||
| 	if (!supported) { | ||||
| 		return null; | ||||
| 	} | ||||
|  | ||||
| 	const styleSheet = props.styles.styleSheet; | ||||
|  | ||||
| 	return ( | ||||
| 		<TouchableNativeFeedback | ||||
| 		<TouchableRipple | ||||
| 			onPress={selectDirectoryButtonPress} | ||||
| 			style={styleSheet.settingContainer} | ||||
| 			role='button' | ||||
| 		> | ||||
| 			<View style={styleSheet.settingContainer}> | ||||
| 				<Text key="label" style={styleSheet.settingText}> | ||||
| @@ -58,7 +78,7 @@ const FileSystemPathSelector: FunctionComponent<Props> = props => { | ||||
| 					{fileSystemPath} | ||||
| 				</Text> | ||||
| 			</View> | ||||
| 		</TouchableNativeFeedback> | ||||
| 		</TouchableRipple> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -4,12 +4,12 @@ import Logger from '@joplin/utils/Logger'; | ||||
| import { FunctionComponent } from 'react'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { join } from 'path'; | ||||
| import Share from 'react-native-share'; | ||||
| import exportAllFolders from './utils/exportAllFolders'; | ||||
| import { ExportProgressState } from '@joplin/lib/services/interop/types'; | ||||
| import { ConfigScreenStyles } from '../configScreenStyles'; | ||||
| import makeImportExportCacheDirectory from './utils/makeImportExportCacheDirectory'; | ||||
| import TaskButton, { OnProgressCallback, SetAfterCompleteListenerCallback, TaskStatus } from './TaskButton'; | ||||
| import shareFile from '../../../../utils/shareFile'; | ||||
|  | ||||
| const logger = Logger.create('NoteExportButton'); | ||||
|  | ||||
| @@ -37,12 +37,7 @@ const runExportTask = async ( | ||||
|  | ||||
| 	setAfterCompleteListener(async (success: boolean) => { | ||||
| 		if (success) { | ||||
| 			await Share.open({ | ||||
| 				type: 'application/jex', | ||||
| 				filename: 'export.jex', | ||||
| 				url: `file://${exportTargetPath}`, | ||||
| 				failOnCancel: false, | ||||
| 			}); | ||||
| 			await shareFile(exportTargetPath, 'application/jex'); | ||||
| 		} | ||||
| 		await shim.fsDriver().remove(exportTargetPath); | ||||
| 	}); | ||||
|   | ||||
| @@ -40,7 +40,7 @@ const runImportTask = async ( | ||||
| 		await shim.fsDriver().remove(importTargetPath); | ||||
| 	}); | ||||
|  | ||||
| 	const importFiles = await pickDocument(false); | ||||
| 	const importFiles = await pickDocument({ multiple: false }); | ||||
| 	if (importFiles.length === 0) { | ||||
| 		logger.info('Canceled.'); | ||||
| 		return { success: false, warnings: [] }; | ||||
| @@ -48,7 +48,7 @@ const runImportTask = async ( | ||||
|  | ||||
| 	const sourceFileUri = importFiles[0].uri; | ||||
| 	const sourceFilePath = Platform.select({ | ||||
| 		android: sourceFileUri, | ||||
| 		default: sourceFileUri, | ||||
| 		ios: decodeURI(sourceFileUri), | ||||
| 	}); | ||||
| 	await shim.fsDriver().copy(sourceFilePath, importTargetPath); | ||||
|   | ||||
| @@ -1,11 +1,12 @@ | ||||
| import * as React from 'react'; | ||||
| import { Alert, Text } from 'react-native'; | ||||
| import { Text } from 'react-native'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { ProgressBar } from 'react-native-paper'; | ||||
| import { FunctionComponent, useCallback, useState } from 'react'; | ||||
| import { ConfigScreenStyles } from '../configScreenStyles'; | ||||
| import SettingsButton from '../SettingsButton'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
|  | ||||
| // Undefined = indeterminate progress | ||||
| export type OnProgressCallback = (progressFraction: number|undefined)=> void; | ||||
| @@ -69,7 +70,10 @@ const TaskButton: FunctionComponent<Props> = props => { | ||||
| 			} | ||||
| 		} catch (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 { | ||||
| 			if (!completedSuccessfully) { | ||||
| 				setTaskStatus(TaskStatus.NotStarted); | ||||
|   | ||||
| @@ -1,9 +1,8 @@ | ||||
|  | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { CachesDirectoryPath } from 'react-native-fs'; | ||||
|  | ||||
| const makeImportExportCacheDirectory = async () => { | ||||
| 	const targetDir = `${CachesDirectoryPath}/exports`; | ||||
| 	const targetDir = `${shim.fsDriver().getCacheDirectoryPath()}/exports`; | ||||
| 	await shim.fsDriver().mkdir(targetDir); | ||||
|  | ||||
| 	return targetDir; | ||||
|   | ||||
| @@ -115,9 +115,10 @@ const SettingComponent: React.FunctionComponent<Props> = props => { | ||||
| 			</View> | ||||
| 		); | ||||
| 	} 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 ( | ||||
| 				<FileSystemPathSelector | ||||
| 					mode={md.key === 'sync.2.path' ? 'readwrite' : 'read'} | ||||
| 					styles={props.styles} | ||||
| 					settingMetadata={md} | ||||
| 					updateSettingValue={props.updateSettingValue} | ||||
|   | ||||
| @@ -53,6 +53,7 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => { | ||||
| 	const settingContainerStyle: ViewStyle = { | ||||
| 		flex: 1, | ||||
| 		flexDirection: 'row', | ||||
| 		flexBasis: 'auto', | ||||
| 		alignItems: 'center', | ||||
| 		borderBottomWidth: 1, | ||||
| 		borderBottomColor: theme.dividerColor, | ||||
| @@ -80,6 +81,7 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => { | ||||
| 	const sidebarButton: SidebarButtonStyle = { | ||||
| 		height: sidebarButtonHeight, | ||||
| 		flex: 1, | ||||
| 		flexBasis: 'auto', | ||||
| 		flexDirection: 'row', | ||||
| 		alignItems: 'center', | ||||
| 		paddingEnd: theme.marginRight, | ||||
| @@ -184,6 +186,7 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => { | ||||
| 			...settingControlStyle, | ||||
| 			color: undefined, | ||||
| 			flex: 0, | ||||
| 			flexBasis: 'auto', | ||||
| 		}, | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -2,26 +2,10 @@ import { _ } from '@joplin/lib/locale'; | ||||
| import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; | ||||
| import * as React from 'react'; | ||||
| import IconButton from '../../../../IconButton'; | ||||
| import { Alert, Linking, StyleSheet } from 'react-native'; | ||||
| import { Linking, StyleSheet } from 'react-native'; | ||||
| import { themeStyle } from '../../../../global-style'; | ||||
| import { useMemo } from 'react'; | ||||
|  | ||||
| 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 }, | ||||
| 	); | ||||
| }; | ||||
| import { useCallback, useContext, useMemo } from 'react'; | ||||
| import { DialogContext } from '../../../../DialogManager'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| @@ -58,6 +42,24 @@ const useStyles = (themeId: number) => { | ||||
| const RecommendedBadge: React.FC<Props> = props => { | ||||
| 	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; | ||||
|  | ||||
| 	return <IconButton | ||||
|   | ||||
| @@ -80,9 +80,10 @@ const PluginBox: React.FC<Props> = props => { | ||||
|  | ||||
| 	const styles = useStyles(props.isCompatible); | ||||
|  | ||||
| 	const CardWrapper = props.onShowPluginInfo ? TouchableRipple : View; | ||||
| 	return ( | ||||
| 		<TouchableRipple | ||||
| 			accessibilityRole='button' | ||||
| 		<CardWrapper | ||||
| 			accessibilityRole={props.onShowPluginInfo ? 'button' : null} | ||||
| 			accessible={true} | ||||
| 			onPress={props.onShowPluginInfo ? onPress : null} | ||||
| 			style={styles.cardContainer} | ||||
| @@ -115,7 +116,7 @@ const PluginBox: React.FC<Props> = props => { | ||||
| 					{props.onInstall ? installButton : null} | ||||
| 				</Card.Actions> | ||||
| 			</Card> | ||||
| 		</TouchableRipple> | ||||
| 		</CardWrapper> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -112,7 +112,10 @@ const PluginStates: React.FC<Props> = props => { | ||||
| 				<Button onPress={reloadPluginRepo}>{_('Retry')}</Button> | ||||
| 			</View>; | ||||
| 		} 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 pluginService = PluginService.instance(); | ||||
|  | ||||
| 		const pluginFiles = await pickDocument(false); | ||||
| 		const pluginFiles = await pickDocument({ multiple: false }); | ||||
| 		if (pluginFiles.length === 0) { | ||||
| 			return; | ||||
| 		} | ||||
| 		const selectedFile = pluginFiles[0]; | ||||
|  | ||||
| 		const localFilePath = Platform.select({ | ||||
| 			android: selectedFile.uri, | ||||
| 			ios: decodeURI(selectedFile.uri), | ||||
| 			default: selectedFile.uri, | ||||
| 		}); | ||||
| 		logger.info('Installing plugin from file', localFilePath); | ||||
|  | ||||
| @@ -73,6 +73,8 @@ const PluginUploadButton: React.FC<Props> = props => { | ||||
| 			logger.info('Copying to', targetFile); | ||||
|  | ||||
| 			await fsDriver.copy(localFilePath, targetFile); | ||||
| 			logger.debug('Copied. Now installing.'); | ||||
|  | ||||
| 			const plugin = await pluginService.installPlugin(targetFile); | ||||
|  | ||||
| 			const pluginSettings = pluginService.unserializePluginSettings(props.pluginSettings); | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; | ||||
| 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 PluginBox, { InstallState } from './PluginBox'; | ||||
| 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 ( | ||||
| 		<View style={styles.container}> | ||||
| 			<TextInput | ||||
| @@ -159,7 +164,7 @@ const PluginSearch: React.FC<Props> = props => { | ||||
| 				data={searchResults} | ||||
| 				renderItem={renderResult} | ||||
| 				keyExtractor={item => item.id} | ||||
| 				scrollEnabled={false} | ||||
| 				scrollEnabled={scrollEnabled} | ||||
| 			/> | ||||
| 		</View> | ||||
| 	); | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| 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 { reg } from '@joplin/lib/registry.js'; | ||||
| import { reg } from '@joplin/lib/registry'; | ||||
| import { ScreenHeader } from '../ScreenHeader'; | ||||
| import time from '@joplin/lib/time'; | ||||
| import { themeStyle } from '../global-style'; | ||||
| @@ -11,10 +11,10 @@ import { BaseScreenComponent } from '../base-screen'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { MenuOptionType } from '../ScreenHeader'; | ||||
| import { AppState } from '../../utils/types'; | ||||
| import Share from 'react-native-share'; | ||||
| import { writeTextToCacheFile } from '../../utils/ShareUtils'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { TextInput } from 'react-native-paper'; | ||||
| import shareFile from '../../utils/shareFile'; | ||||
|  | ||||
| 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 | ||||
| 			// and blank share sheet on iOS for larger log files (around 200 KiB). | ||||
| 			fileToShare = await writeTextToCacheFile(logData, 'mobile-log.log'); | ||||
|  | ||||
| 			await Share.open({ | ||||
| 				type: 'text/plain', | ||||
| 				filename: 'log.txt', | ||||
| 				url: `file://${fileToShare}`, | ||||
| 				failOnCancel: false, | ||||
| 			}); | ||||
| 			await shareFile(fileToShare, 'text/plain'); | ||||
| 		} catch (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). | ||||
| 			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 { | ||||
| 			if (fileToShare) { | ||||
| 				await shim.fsDriver().remove(fileToShare); | ||||
| @@ -225,6 +219,7 @@ class LogScreenComponent extends BaseScreenComponent<Props, State> { | ||||
| 				{this.state.filter !== undefined ? filterInput : null} | ||||
| 				<FlatList | ||||
| 					data={this.state.logEntries} | ||||
| 					initialNumToRender={100} | ||||
| 					renderItem={this.onRenderLogRow} | ||||
| 					keyExtractor={item => { return `${item.id}`; }} | ||||
| 				/> | ||||
|   | ||||
| @@ -6,7 +6,6 @@ import UndoRedoService from '@joplin/lib/services/UndoRedoService'; | ||||
| import NoteBodyViewer from '../NoteBodyViewer/NoteBodyViewer'; | ||||
| import checkPermissions from '../../utils/checkPermissions'; | ||||
| import NoteEditor from '../NoteEditor/NoteEditor'; | ||||
| const FileViewer = require('react-native-file-viewer').default; | ||||
| const React = require('react'); | ||||
| import { Keyboard, View, TextInput, StyleSheet, Linking, Share, NativeSyntheticEvent } 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 { BackButtonService } = require('../../services/back-button.js'); | ||||
| import NavService, { OnNavigateCallback as OnNavigateCallback } from '@joplin/lib/services/NavService'; | ||||
| import BaseModel, { ModelType } from '@joplin/lib/BaseModel'; | ||||
| import ActionButton from '../ActionButton'; | ||||
| import { ModelType } from '@joplin/lib/BaseModel'; | ||||
| import FloatingActionButton from '../buttons/FloatingActionButton'; | ||||
| const { fileExtension, safeFileExtension } = require('@joplin/lib/path-utils'); | ||||
| import * as mimeUtils from '@joplin/lib/mime-utils'; | ||||
| import ScreenHeader, { MenuOptionType } from '../ScreenHeader'; | ||||
| @@ -62,7 +61,7 @@ import pickDocument from '../../utils/pickDocument'; | ||||
| import debounce from '../../utils/debounce'; | ||||
| import { focus } from '@joplin/lib/utils/focusHandler'; | ||||
| 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 resizeImage from '../../utils/image/resizeImage'; | ||||
|  | ||||
| @@ -105,7 +104,7 @@ interface State { | ||||
| 	showImageEditor: boolean; | ||||
| 	imageEditorResource: ResourceEntity; | ||||
| 	imageEditorResourceFilepath: string; | ||||
| 	noteResources: Record<string, ResourceEntity>; | ||||
| 	noteResources: Record<string, ResourceInfo>; | ||||
| 	newAndNoTitleChangeNoteId: boolean|null; | ||||
|  | ||||
| 	HACK_webviewLoadingState: number; | ||||
| @@ -269,35 +268,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
|  | ||||
| 		this.onJoplinLinkClick_ = async (msg: string) => { | ||||
| 			try { | ||||
| 				const resourceUrlInfo = urlUtils.parseResourceUrl(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); | ||||
| 					} | ||||
| 				} | ||||
| 				await CommandService.instance().execute('openItem', msg); | ||||
| 			} catch (error) { | ||||
| 				dialogs.error(this, error.message); | ||||
| 			} | ||||
| @@ -460,6 +431,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
| 		styles.titleContainer = { | ||||
| 			flex: 0, | ||||
| 			flexDirection: 'row', | ||||
| 			flexBasis: 'auto', | ||||
| 			paddingLeft: theme.marginLeft, | ||||
| 			paddingRight: theme.marginRight, | ||||
| 			borderBottomColor: theme.dividerColor, | ||||
| @@ -493,6 +465,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
|  | ||||
| 	public async requestGeoLocationPermissions() { | ||||
| 		if (!Setting.value('trackLocation')) return; | ||||
| 		if (Platform.OS === 'web') return; | ||||
|  | ||||
| 		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.'), | ||||
| @@ -670,7 +643,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
| 	} | ||||
|  | ||||
| 	private async pickDocuments() { | ||||
| 		const result = await pickDocument(true); | ||||
| 		const result = await pickDocument({ multiple: true }); | ||||
| 		return result; | ||||
| 	} | ||||
|  | ||||
| @@ -726,8 +699,8 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
| 		} | ||||
|  | ||||
| 		const localFilePath = Platform.select({ | ||||
| 			android: pickerResponse.uri, | ||||
| 			ios: decodeURI(pickerResponse.uri), | ||||
| 			default: pickerResponse.uri, | ||||
| 		}); | ||||
|  | ||||
| 		let mimeType = pickerResponse.type; | ||||
| @@ -849,9 +822,16 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	private takePhoto_onPress() { | ||||
| 	private async takePhoto_onPress() { | ||||
| 		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 | ||||
| 	private cameraView_onPhoto(data: any) { | ||||
| @@ -994,6 +974,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
| 	} | ||||
|  | ||||
| 	public async onAlarmDialogAccept(date: Date) { | ||||
| 		if (Platform.OS === 'android') { | ||||
| 			const response = await checkPermissions(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS); | ||||
|  | ||||
| 			// The POST_NOTIFICATIONS permission isn't supported on Android API < 33. | ||||
| @@ -1003,6 +984,11 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
| 				logger.warn('POST_NOTIFICATIONS permission was not granted'); | ||||
| 				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 }; | ||||
| 		newNote.todo_due = date ? date.getTime() : 0; | ||||
| @@ -1226,6 +1212,8 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		const shareSupported = Platform.OS !== 'web' || !!navigator.share; | ||||
| 		if (shareSupported) { | ||||
| 			output.push({ | ||||
| 				title: _('Share'), | ||||
| 				onPress: () => { | ||||
| @@ -1233,6 +1221,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
| 				}, | ||||
| 				disabled: readOnly, | ||||
| 			}); | ||||
| 		} | ||||
|  | ||||
| 		// Voice typing is enabled only for French language and on Android for now | ||||
| 		if (voskEnabled && shim.mobilePlatform() === 'android' && isSupportedLanguage(currentLocale())) { | ||||
| @@ -1270,6 +1259,9 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
| 					this.copyMarkdownLink_onPress(); | ||||
| 				}, | ||||
| 			}); | ||||
|  | ||||
| 			// External links are not supported on web. | ||||
| 			if (Platform.OS !== 'web') { | ||||
| 				output.push({ | ||||
| 					title: _('Copy external link'), | ||||
| 					onPress: () => { | ||||
| @@ -1277,6 +1269,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
| 					}, | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		output.push({ | ||||
| 			title: _('Properties'), | ||||
| @@ -1584,7 +1577,7 @@ class NoteScreenComponent extends BaseScreenComponent<Props, State> implements B | ||||
|  | ||||
| 			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 | ||||
|   | ||||
| @@ -10,7 +10,7 @@ import Setting from '@joplin/lib/models/Setting'; | ||||
| import { themeStyle } from '../global-style'; | ||||
| import { ScreenHeader } from '../ScreenHeader'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import ActionButton from '../ActionButton'; | ||||
| import ActionButton from '../buttons/FloatingActionButton'; | ||||
| const { dialogs } = require('../../utils/dialogs.js'); | ||||
| const DialogBox = require('react-native-dialogbox').default; | ||||
| const { BaseScreenComponent } = require('../base-screen'); | ||||
| @@ -18,6 +18,7 @@ const { BackButtonService } = require('../../services/back-button.js'); | ||||
| import { AppState } from '../../utils/types'; | ||||
| import { NoteEntity } from '@joplin/lib/services/database/types'; | ||||
| 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 | ||||
| class NotesScreenComponent extends BaseScreenComponent<any> { | ||||
| @@ -264,16 +265,13 @@ class NotesScreenComponent extends BaseScreenComponent<any> { | ||||
| 		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. | ||||
| 		// accessibilityElementsHidden is used on iOS and importantForAccessibility is used | ||||
| 		// on Android. | ||||
| 		const accessibilityHidden = !this.props.visible; | ||||
|  | ||||
| 		return ( | ||||
| 			<View | ||||
| 			<AccessibleView | ||||
| 				style={rootStyle} | ||||
|  | ||||
| 				accessibilityElementsHidden={accessibilityHidden} | ||||
| 				importantForAccessibility={accessibilityHidden ? 'no-hide-descendants' : undefined} | ||||
| 				inert={accessibilityHidden} | ||||
| 			> | ||||
| 				<ScreenHeader title={iconString + title} showBackButton={false} parentComponent={thisComp} sortButton_press={this.sortButton_press} folderPickerOptions={this.folderPickerOptions()} showSearchButton={true} showSideMenuButton={true} /> | ||||
| 				<NoteList /> | ||||
| @@ -284,7 +282,7 @@ class NotesScreenComponent extends BaseScreenComponent<any> { | ||||
| 						this.dialogbox = dialogbox; | ||||
| 					}} | ||||
| 				/> | ||||
| 			</View> | ||||
| 			</AccessibleView> | ||||
| 		); | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -153,7 +153,7 @@ const EncryptionConfigScreen = (props: Props) => { | ||||
| 		}); | ||||
|  | ||||
| 		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> | ||||
| 				<Text style={styles.normalText}>{_('Password:')}</Text> | ||||
| 				<TextInput | ||||
|   | ||||
| @@ -5,7 +5,7 @@ const { Button } = require('react-native'); | ||||
| const { WebView } = require('react-native-webview'); | ||||
| const { connect } = require('react-redux'); | ||||
| const { ScreenHeader } = require('../ScreenHeader'); | ||||
| const { reg } = require('@joplin/lib/registry.js'); | ||||
| const { reg } = require('@joplin/lib/registry'); | ||||
| const { _ } = require('@joplin/lib/locale'); | ||||
| const { BaseScreenComponent } = require('../base-screen'); | ||||
| const parseUri = require('@joplin/lib/parseUri'); | ||||
|   | ||||
| @@ -48,6 +48,7 @@ class StatusScreenComponent extends BaseScreenComponent<Props, State> { | ||||
| 			}, | ||||
| 			actionButton: { | ||||
| 				flex: 0, | ||||
| 				flexBasis: 'auto', | ||||
| 				marginLeft: 2, | ||||
| 				marginRight: 2, | ||||
| 			}, | ||||
|   | ||||
| @@ -25,6 +25,7 @@ class SideMenuContentNoteComponent extends Component { | ||||
| 			}, | ||||
| 			button: { | ||||
| 				flex: 1, | ||||
| 				flexBasis: 'auto', | ||||
| 				flexDirection: 'row', | ||||
| 				height: 36, | ||||
| 				alignItems: 'center', | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| const React = require('react'); | ||||
| import { useMemo, useEffect, useCallback } from 'react'; | ||||
| const { Easing, Animated, TouchableOpacity, Text, StyleSheet, ScrollView, View, Alert, Image } = require('react-native'); | ||||
| import { useMemo, useEffect, useCallback, useContext } from 'react'; | ||||
| const { Easing, Animated, TouchableOpacity, Text, StyleSheet, ScrollView, View, Image } = require('react-native'); | ||||
| const { connect } = require('react-redux'); | ||||
| const Icon = require('react-native-vector-icons/Ionicons').default; | ||||
| 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 emptyTrash from '@joplin/lib/services/trash/emptyTrash'; | ||||
| 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'); | ||||
|  | ||||
| interface Props { | ||||
| @@ -71,6 +74,7 @@ const SideMenuContentComponent = (props: Props) => { | ||||
| 			button: { | ||||
| 				flex: 1, | ||||
| 				flexDirection: 'row', | ||||
| 				flexBasis: 'auto', | ||||
| 				height: 36, | ||||
| 				alignItems: 'center', | ||||
| 				paddingLeft: theme.marginLeft, | ||||
| @@ -144,6 +148,8 @@ const SideMenuContentComponent = (props: Props) => { | ||||
| 		}); | ||||
| 	}; | ||||
|  | ||||
| 	const dialogs = useContext(DialogContext); | ||||
|  | ||||
| 	const folder_longPress = async (folderOrAll: FolderEntity | string) => { | ||||
| 		if (folderOrAll === 'all') return; | ||||
|  | ||||
| @@ -156,7 +162,7 @@ const SideMenuContentComponent = (props: Props) => { | ||||
| 			menuItems.push({ | ||||
| 				text: _('Empty trash'), | ||||
| 				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'), | ||||
| 							onPress: async () => { | ||||
| @@ -206,7 +212,7 @@ const SideMenuContentComponent = (props: Props) => { | ||||
| 		} else { | ||||
| 			const generateFolderDeletion = () => { | ||||
| 				const folderDeletion = (message: string) => { | ||||
| 					Alert.alert('', message, [ | ||||
| 					dialogs.prompt('', message, [ | ||||
| 						{ | ||||
| 							text: _('OK'), | ||||
| 							onPress: () => { | ||||
| @@ -255,13 +261,10 @@ const SideMenuContentComponent = (props: Props) => { | ||||
| 			style: 'cancel', | ||||
| 		}); | ||||
|  | ||||
| 		Alert.alert( | ||||
| 		dialogs.prompt( | ||||
| 			'', | ||||
| 			_('Notebook: %s', folder.title), | ||||
| 			menuItems, | ||||
| 			{ | ||||
| 				cancelable: false, | ||||
| 			}, | ||||
| 		); | ||||
| 	}; | ||||
|  | ||||
| @@ -400,6 +403,7 @@ const SideMenuContentComponent = (props: Props) => { | ||||
| 		const folderButtonStyle: any = { | ||||
| 			flex: 1, | ||||
| 			flexDirection: 'row', | ||||
| 			flexBasis: 'auto', | ||||
| 			height: 36, | ||||
| 			alignItems: 'center', | ||||
| 			paddingRight: theme.marginRight, | ||||
| @@ -438,14 +442,19 @@ const SideMenuContentComponent = (props: Props) => { | ||||
|  | ||||
| 		return ( | ||||
| 			<View key={folder.id} style={{ flex: 1, flexDirection: 'row' }}> | ||||
| 				<TouchableOpacity | ||||
| 					style={{ flex: 1 }} | ||||
| 				<TouchableRipple | ||||
| 					style={{ flex: 1, flexBasis: 'auto' }} | ||||
| 					onPress={() => { | ||||
| 						folder_press(folder); | ||||
| 					}} | ||||
| 					onLongPress={() => { | ||||
| 						void folder_longPress(folder); | ||||
| 					}} | ||||
| 					onContextMenu={(event: Event) => { // web only | ||||
| 						event.preventDefault(); | ||||
| 						void folder_longPress(folder); | ||||
| 					}} | ||||
| 					role='button' | ||||
| 				> | ||||
| 					<View style={folderButtonStyle}> | ||||
| 						{renderFolderIcon(folder.id, theme, folderIcon)} | ||||
| @@ -453,7 +462,7 @@ const SideMenuContentComponent = (props: Props) => { | ||||
| 							{Folder.displayTitle(folder)} | ||||
| 						</Text> | ||||
| 					</View> | ||||
| 				</TouchableOpacity> | ||||
| 				</TouchableRipple> | ||||
| 				{iconWrapper} | ||||
| 			</View> | ||||
| 		); | ||||
| @@ -461,7 +470,7 @@ const SideMenuContentComponent = (props: Props) => { | ||||
|  | ||||
| 	// 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) => { | ||||
| 		let icon = <Icon name={iconName} style={styles_.sidebarIcon} />; | ||||
| 		let icon = <Icon name={iconName} style={styles_.sidebarIcon} aria-hidden={true} />; | ||||
|  | ||||
| 		if (key === 'synchronize_button') { | ||||
| 			icon = <Animated.View style={{ transform: [{ rotate: syncIconRotation }] }}>{icon}</Animated.View>; | ||||
| @@ -477,7 +486,7 @@ const SideMenuContentComponent = (props: Props) => { | ||||
| 		if (!onPressHandler) return content; | ||||
|  | ||||
| 		return ( | ||||
| 			<TouchableOpacity key={key} onPress={onPressHandler}> | ||||
| 			<TouchableOpacity key={key} onPress={onPressHandler} role='button'> | ||||
| 				{content} | ||||
| 			</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 = []; | ||||
| @@ -587,15 +596,13 @@ const SideMenuContentComponent = (props: Props) => { | ||||
| 		opacity: isHidden ? 0.5 : undefined, | ||||
| 	}; | ||||
|  | ||||
| 	// Note: iOS uses accessibilityElementsHidden and Android uses importantForAccessibility | ||||
| 	//       to hide elements from the screenreader. | ||||
|  | ||||
| 	return ( | ||||
| 		<View | ||||
| 		<AccessibleView | ||||
| 			style={style} | ||||
|  | ||||
| 			accessibilityElementsHidden={isHidden} | ||||
| 			importantForAccessibility={isHidden ? 'no-hide-descendants' : undefined} | ||||
| 			// Accessibility, keyboard, and touch hidden. | ||||
| 			inert={isHidden} | ||||
| 			refocusCounter={isHidden ? undefined : 1} | ||||
| 		> | ||||
| 			<View style={{ flex: 1, opacity: props.opacity }}> | ||||
| 				<ScrollView scrollsToTop={false} style={styles_.menu}> | ||||
| @@ -603,7 +610,7 @@ const SideMenuContentComponent = (props: Props) => { | ||||
| 				</ScrollView> | ||||
| 				{renderBottomPanel()} | ||||
| 			</View> | ||||
| 		</View> | ||||
| 		</AccessibleView> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -7,6 +7,9 @@ const tasks = { | ||||
| 	encodeAssets: { | ||||
| 		fn: require('./tools/encodeAssets'), | ||||
| 	}, | ||||
| 	copyWebAssets: { | ||||
| 		fn: require('./tools/copyAssets').default, | ||||
| 	}, | ||||
| 	...injectedJsGulpTasks, | ||||
| 	podInstall: { | ||||
| 		fn: require('./tools/podInstall'), | ||||
| @@ -37,6 +40,7 @@ gulp.task('watchInjectedJs', gulp.series( | ||||
|  | ||||
| gulp.task('build', gulp.series( | ||||
| 	'buildInjectedJs', | ||||
| 	'copyWebAssets', | ||||
| 	'encodeAssets', | ||||
| 	'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({ | ||||
| 	nodeSqlite: sqlite3, | ||||
| 	appVersion: () => require('./package.json').version, | ||||
| 	React, | ||||
| 	sharp, | ||||
| }); | ||||
| @@ -100,6 +101,10 @@ jest.doMock('react-native-fs', () => { | ||||
| 	}; | ||||
| }); | ||||
|  | ||||
| shim.fsDriver().getCacheDirectoryPath = () => { | ||||
| 	return tempDirectoryPath; | ||||
| }; | ||||
|  | ||||
| beforeAll(async () => { | ||||
| 	await mkdir(tempDirectoryPath); | ||||
| }); | ||||
|   | ||||
| @@ -8,6 +8,8 @@ | ||||
|     "start": "BROWSERSLIST_IGNORE_OLD_DATA=true react-native start --reset-cache", | ||||
|     "android": "react-native run-android", | ||||
|     "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", | ||||
|     "watch": "tsc --watch --preserveWatchOutput --project tsconfig.json", | ||||
|     "clean": "node tools/clean.js", | ||||
| @@ -83,13 +85,15 @@ | ||||
|     "url": "0.11.3" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@babel/core": "7.20.2", | ||||
|     "@babel/preset-env": "7.20.2", | ||||
|     "@babel/runtime": "7.20.0", | ||||
|     "@babel/core": "7.24.7", | ||||
|     "@babel/plugin-transform-export-namespace-from": "7.24.7", | ||||
|     "@babel/preset-env": "7.24.7", | ||||
|     "@babel/runtime": "7.24.7", | ||||
|     "@joplin/tools": "~3.0", | ||||
|     "@js-draw/material-icons": "1.20.0", | ||||
|     "@react-native/babel-preset": "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/react-native": "12.3.3", | ||||
|     "@tsconfig/react-native": "2.0.2", | ||||
| @@ -98,10 +102,12 @@ | ||||
|     "@types/react": "18.2.58", | ||||
|     "@types/react-native": "0.70.6", | ||||
|     "@types/react-redux": "7.1.33", | ||||
|     "@types/serviceworker": "0.0.88", | ||||
|     "@types/tar-stream": "3.1.3", | ||||
|     "babel-jest": "29.7.0", | ||||
|     "babel-loader": "9.1.3", | ||||
|     "babel-plugin-module-resolver": "4.1.0", | ||||
|     "babel-plugin-react-native-web": "0.19.12", | ||||
|     "fs-extra": "11.2.0", | ||||
|     "gulp": "4.0.2", | ||||
|     "jest": "29.7.0", | ||||
| @@ -111,15 +117,21 @@ | ||||
|     "jsdom": "23.2.0", | ||||
|     "nodemon": "3.0.3", | ||||
|     "punycode": "2.3.1", | ||||
|     "react-dom": "18.3.1", | ||||
|     "react-native-web": "0.19.12", | ||||
|     "react-test-renderer": "18.2.0", | ||||
|     "sharp": "0.33.2", | ||||
|     "sqlite3": "5.1.6", | ||||
|     "timers-browserify": "2.0.12", | ||||
|     "ts-jest": "29.1.1", | ||||
|     "ts-loader": "9.5.1", | ||||
|     "ts-node": "10.9.2", | ||||
|     "typescript": "5.2.2", | ||||
|     "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": { | ||||
|     "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 initProfile from '@joplin/lib/services/profileConfig/initProfile'; | ||||
| const VersionInfo = require('react-native-version-info').default; | ||||
| const { Keyboard, BackHandler, Animated, View, StatusBar, Platform, Dimensions } = require('react-native'); | ||||
| import { AppState as RNAppState, EmitterSubscription, Linking, NativeEventSubscription, Appearance, AccessibilityInfo } from 'react-native'; | ||||
| const { Keyboard, BackHandler, Animated, StatusBar, Platform, Dimensions } = require('react-native'); | ||||
| import { AppState as RNAppState, EmitterSubscription, View, Text, Linking, NativeEventSubscription, Appearance, AccessibilityInfo, ActivityIndicator } from 'react-native'; | ||||
| import getResponsiveValue from './components/getResponsiveValue'; | ||||
| import NetInfo from '@react-native-community/netinfo'; | ||||
| const DropdownAlert = require('react-native-dropdownalert').default; | ||||
| @@ -69,7 +69,6 @@ import { MenuProvider } from 'react-native-popup-menu'; | ||||
| import SideMenu from './components/SideMenu'; | ||||
| import SideMenuContent from './components/side-menu-content'; | ||||
| const { SideMenuContentNote } = require('./components/side-menu-content-note.js'); | ||||
| const { DatabaseDriverReactNative } = require('./utils/database-driver-react-native'); | ||||
| import { reg } from '@joplin/lib/registry'; | ||||
| const { defaultState } = require('@joplin/lib/reducer'); | ||||
| 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 JoplinCloudLoginScreen from './components/screens/JoplinCloudLoginScreen'; | ||||
|  | ||||
| import SyncTargetNone from '@joplin/lib/SyncTargetNone'; | ||||
|  | ||||
| SyncTargetRegistry.addClass(SyncTargetNone); | ||||
| SyncTargetRegistry.addClass(SyncTargetOneDrive); | ||||
| SyncTargetRegistry.addClass(SyncTargetNextcloud); | ||||
| @@ -107,7 +108,6 @@ import setIgnoreTlsErrors from './utils/TlsUtils'; | ||||
| import ShareService from '@joplin/lib/services/share/ShareService'; | ||||
| import setupNotifications from './utils/setupNotifications'; | ||||
| import { loadMasterKeysFromSettings, migrateMasterPassword } from '@joplin/lib/services/e2ee/utils'; | ||||
| import SyncTargetNone from '@joplin/lib/SyncTargetNone'; | ||||
| import { setRSA } from '@joplin/lib/services/e2ee/ppk'; | ||||
| import RSA from './services/e2ee/RSA.react-native'; | ||||
| 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 appDefaultState, { DEFAULT_ROUTE } from './utils/appDefaultState'; | ||||
| 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 { getDisplayParentId } from '@joplin/lib/services/trash'; | ||||
|  | ||||
| @@ -500,6 +503,8 @@ const getInitialActiveFolder = async () => { | ||||
| 	return await Folder.load(folderId); | ||||
| }; | ||||
|  | ||||
| const singleInstanceLock = lockToSingleInstance(); | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied | ||||
| async function initialize(dispatch: Dispatch) { | ||||
| 	shimInit(); | ||||
| @@ -525,6 +530,10 @@ async function initialize(dispatch: Dispatch) { | ||||
|  | ||||
| 	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()); | ||||
| 	await logDatabase.open({ name: 'log.sqlite' }); | ||||
| 	await logDatabase.exec(Logger.databaseCreateTableSql()); | ||||
| @@ -627,6 +636,16 @@ async function initialize(dispatch: Dispatch) { | ||||
| 			const detectedLocale = shim.detectAndSetLocale(Setting); | ||||
| 			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.setValue('firstStart', false); | ||||
| 		} else { | ||||
| @@ -655,7 +674,6 @@ async function initialize(dispatch: Dispatch) { | ||||
| 			Setting.setValue('welcome.enabled', false); | ||||
| 		} | ||||
|  | ||||
| 		PluginAssetsLoader.instance().setLogger(mainLogger); | ||||
| 		await PluginAssetsLoader.instance().importAssets(); | ||||
|  | ||||
| 		// eslint-disable-next-line require-atomic-updates | ||||
| @@ -826,7 +844,11 @@ async function initialize(dispatch: Dispatch) { | ||||
| 	// just print some messages in the console. | ||||
| 	// ---------------------------------------------------------------------------- | ||||
| 	if (Setting.value('env') === 'dev') { | ||||
| 		if (Platform.OS !== 'web') { | ||||
| 			await runRsaIntegrationTests(); | ||||
| 		} else { | ||||
| 			logger.info('Skipping RSA tests -- not supported on mobile.'); | ||||
| 		} | ||||
| 		await runOnDeviceFsDriverTests(); | ||||
| 	} | ||||
|  | ||||
| @@ -930,7 +952,7 @@ class AppComponent extends React.Component { | ||||
| 				// This will be called right after adding the event listener | ||||
| 				// so there's no need to check netinfo on startup | ||||
| 				this.unsubscribeNetInfoHandler_ = NetInfo.addEventListener(({ type, details }) => { | ||||
| 					const isMobile = details.isConnectionExpensive || type === 'cellular'; | ||||
| 					const isMobile = details?.isConnectionExpensive || type === 'cellular'; | ||||
| 					reg.setIsOnMobileData(isMobile); | ||||
| 					this.props.dispatch({ | ||||
| 						type: 'MOBILE_DATA_WARNING_UPDATE', | ||||
| @@ -942,7 +964,16 @@ class AppComponent extends React.Component { | ||||
| 				reg.logger().info(error); | ||||
| 			} | ||||
|  | ||||
| 			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(); | ||||
| 			this.setState({ sensorInfo: loadedSensorInfo }); | ||||
| @@ -1169,7 +1200,20 @@ class AppComponent extends React.Component { | ||||
| 	}; | ||||
|  | ||||
| 	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); | ||||
|  | ||||
| 		let sideMenuContent: ReactNode = null; | ||||
| @@ -1300,7 +1344,9 @@ class AppComponent extends React.Component { | ||||
| 					}, | ||||
| 				}, | ||||
| 			}}> | ||||
| 				<DialogManager> | ||||
| 					{mainContent} | ||||
| 				</DialogManager> | ||||
| 			</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 shim from '@joplin/lib/shim'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| const RnRSA = require('react-native-rsa-native').RSA; | ||||
|  | ||||
| interface RSAKeyPair { | ||||
| @@ -7,9 +9,18 @@ interface RSAKeyPair { | ||||
| 	keySizeBits: number; | ||||
| } | ||||
|  | ||||
| const logger = Logger.create('RSA'); | ||||
|  | ||||
| const rsa: RSA = { | ||||
|  | ||||
| 	generateKeyPair: async (keySize: number): Promise<RSAKeyPair> => { | ||||
| 		if (shim.mobilePlatform() === 'web') { | ||||
| 			// TODO: Try to implement with SubtleCrypto. May require migrating the RSA algorithm used on | ||||
| 			// desktop and mobile (which is not supported on web). See commit 12adcd9dbc3f723bac36ff4447701573084c4694. | ||||
| 			logger.warn('RSA on web is not yet supported.'); | ||||
| 			return null; | ||||
| 		} | ||||
|  | ||||
| 		const keys: RSAKeyPair = await RnRSA.generateKeys(keySize); | ||||
|  | ||||
| 		// Sanity check | ||||
|   | ||||
| @@ -4,7 +4,6 @@ import Setting from '@joplin/lib/models/Setting'; | ||||
| import { reg } from '@joplin/lib/registry'; | ||||
| import BasePlatformImplementation, { Joplin } from '@joplin/lib/services/plugins/BasePlatformImplementation'; | ||||
| 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 shim from '@joplin/lib/shim'; | ||||
| import Clipboard from '@react-native-clipboard/clipboard'; | ||||
| @@ -32,7 +31,7 @@ export default class PlatformImplementation extends BasePlatformImplementation { | ||||
|  | ||||
| 	public get versionInfo(): VersionInfo { | ||||
| 		return { | ||||
| 			version: RNVersionInfo.appVersion, | ||||
| 			version: shim.appVersion(), | ||||
| 			syncVersion: Setting.value('syncVersion'), | ||||
| 			profileVersion: reg.db().version(), | ||||
| 			platform: 'mobile', | ||||
|   | ||||
| @@ -1,10 +1,9 @@ | ||||
| // Helper functions to reduce the boiler plate of loading and saving profiles on | ||||
| // mobile | ||||
|  | ||||
| const RNExitApp = require('react-native-exit-app').default; | ||||
| import { Profile, ProfileConfig } from '@joplin/lib/services/profileConfig/types'; | ||||
| 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 | ||||
| let dispatch_: Function = null; | ||||
| @@ -14,7 +13,7 @@ export const setDispatch = (dispatch: Function) => { | ||||
| }; | ||||
|  | ||||
| export const getProfilesRootDir = () => { | ||||
| 	return RNFetchBlob.fs.dirs.DocumentDir; | ||||
| 	return shim.fsDriver().getAppDirectoryPath(); | ||||
| }; | ||||
|  | ||||
| export const getProfilesConfigPath = () => { | ||||
| @@ -55,5 +54,5 @@ export const switchProfile = async (profileId: string) => { | ||||
|  | ||||
| 	config.currentProfileId = profileId; | ||||
| 	await saveProfileConfig(config); | ||||
| 	RNExitApp.exitApp(); | ||||
| 	shim.restartApp(); | ||||
| }; | ||||
|   | ||||
| @@ -13,6 +13,12 @@ type TData = { | ||||
|  | ||||
| export default async (dispatch: Dispatch) => { | ||||
| 	const userInfo = { url: '' }; | ||||
|  | ||||
| 	if (!QuickActions.setShortcutItems) { | ||||
| 		logger.info('QuickActions unsupported'); | ||||
| 		return null; | ||||
| 	} | ||||
|  | ||||
| 	QuickActions.setShortcutItems([ | ||||
| 		{ type: 'New note', title: _('New note'), icon: 'Compose', 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 fs = require('fs-extra'); | ||||
| const path = require('path'); | ||||
| const md5 = require('md5'); | ||||
|  | ||||
| const rootDir = `${__dirname}/..`; | ||||
| @@ -69,6 +70,10 @@ async function main() { | ||||
| 			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.web.js`, `module.exports = ${JSON.stringify({ | ||||
| 				hash, | ||||
| 				files: files.map(file => path.relative(sourceAssetDir, file)), | ||||
| 			})}`); | ||||
|  | ||||
| 			return; | ||||
| 		} catch (error) { | ||||
|   | ||||
| @@ -1,13 +1,12 @@ | ||||
| import Resource from '@joplin/lib/models/Resource'; | ||||
| import { ResourceEntity } from '@joplin/lib/services/database/types'; | ||||
| 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 | ||||
| const DIR_NAME = 'sharedFiles'; | ||||
|  | ||||
| const makeShareCacheDirectory = async () => { | ||||
| 	const targetDir = `${CachesDirectoryPath}/${DIR_NAME}`; | ||||
| 	const targetDir = `${shim.fsDriver().getCacheDirectoryPath()}/${DIR_NAME}`; | ||||
| 	await shim.fsDriver().mkdir(targetDir); | ||||
|  | ||||
| 	return targetDir; | ||||
| @@ -37,5 +36,5 @@ export const writeTextToCacheFile = async (text: string, fileName: string): Prom | ||||
|  | ||||
| // Clear previously shared files from cache | ||||
| 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; | ||||
| 	} | ||||
|  | ||||
| 	public getCacheDirectoryPath() { | ||||
| 		return RNFS.CachesDirectoryPath; | ||||
| 	} | ||||
|  | ||||
| 	public getAppDirectoryPath() { | ||||
| 		return RNFetchBlob.fs.dirs.DocumentDir; | ||||
| 	} | ||||
|  | ||||
| 	public isUsingAndroidSAF() { | ||||
| 		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.close(filePath); | ||||
| 		await fsDriver.close(handle); | ||||
| 	} | ||||
|  | ||||
| 	// Should throw when the file doesn't exist | ||||
| @@ -261,6 +261,7 @@ const runOnDeviceTests = async () => { | ||||
| 	if (await shim.fsDriver().exists(tempDir)) { | ||||
| 		await shim.fsDriver().remove(tempDir); | ||||
| 	} | ||||
| 	await shim.fsDriver().mkdir(tempDir); | ||||
|  | ||||
| 	try { | ||||
| 		await testExpect(); | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { pack as tarStreamPack } from 'tar-stream'; | ||||
| import { resolve } from 'path'; | ||||
| import * as RNFS from 'react-native-fs'; | ||||
| import { Buffer } from 'buffer'; | ||||
|  | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import { chunkSize } from './constants'; | ||||
| @@ -8,7 +8,7 @@ import shim from '@joplin/lib/shim'; | ||||
|  | ||||
| const logger = Logger.create('fs-driver-rn'); | ||||
|  | ||||
| interface TarCreateOptions { | ||||
| export interface TarCreateOptions { | ||||
| 	cwd: string; | ||||
| 	file: string; | ||||
| } | ||||
| @@ -18,7 +18,7 @@ interface TarCreateOptions { | ||||
|  | ||||
| const tarCreate = async (options: TarCreateOptions, filePaths: string[]) => { | ||||
| 	// 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 fsDriver = shim.fsDriver(); | ||||
| @@ -28,6 +28,12 @@ const tarCreate = async (options: TarCreateOptions, filePaths: string[]) => { | ||||
|  | ||||
| 	const pack = tarStreamPack(); | ||||
|  | ||||
| 	const errors: Error[] = []; | ||||
| 	pack.addListener('error', error => { | ||||
| 		logger.error(`Tar error: ${error}`); | ||||
| 		errors.push(error); | ||||
| 	}); | ||||
|  | ||||
| 	for (const path of filePaths) { | ||||
| 		const absPath = resolve(cwd, path); | ||||
| 		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) { | ||||
| 			// The RNFS documentation suggests using base64 for binary files. | ||||
| 			const part = await RNFS.read(absPath, chunkSize, offset, 'base64'); | ||||
| 			entry.write(Buffer.from(part, 'base64')); | ||||
| 		const handle = await shim.fsDriver().open(absPath, 'rw'); | ||||
|  | ||||
| 		let offset = 0; | ||||
| 		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(); | ||||
| 	} | ||||
| @@ -57,6 +69,10 @@ const tarCreate = async (options: TarCreateOptions, filePaths: string[]) => { | ||||
| 		const base64Data = buff.toString('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; | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import { resolve, dirname } from 'path'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { chunkSize } from './constants'; | ||||
|  | ||||
| interface TarExtractOptions { | ||||
| export interface TarExtractOptions { | ||||
| 	cwd: string; | ||||
| 	file: string; | ||||
| } | ||||
| @@ -68,8 +68,7 @@ const tarExtract = async (options: TarExtractOptions) => { | ||||
|  | ||||
| 	const fileHandle = await fsDriver.open(filePath, 'r'); | ||||
| 	const readChunk = async () => { | ||||
| 		const base64 = await fsDriver.readFileChunk(fileHandle, chunkSize, 'base64'); | ||||
| 		return base64 && Buffer.from(base64, 'base64'); | ||||
| 		return await fsDriver.readFileChunkAsBuffer(fileHandle, chunkSize); | ||||
| 	}; | ||||
|  | ||||
| 	try { | ||||
|   | ||||
| @@ -19,8 +19,12 @@ const getWebViewVersionText = () => { | ||||
| const getOSVersion = (): string => { | ||||
| 	if (Platform.OS === 'android') { | ||||
| 		return _('Android API level: %d', Platform.Version); | ||||
| 	} else { | ||||
| 	} else if (Platform.OS === 'ios') { | ||||
| 		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 { 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> => { | ||||
| 	if (uri.startsWith('/')) { | ||||
| 		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) => { | ||||
| 		NativeImage.getSize( | ||||
| 			uri, | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| 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'); | ||||
|  | ||||
| @@ -16,6 +18,42 @@ interface Options { | ||||
| } | ||||
|  | ||||
| const resizeImage = async (options: Options) => { | ||||
| 	if (shim.mobilePlatform() === 'web') { | ||||
| 		const image = await fileToImage(options.inputPath); | ||||
| 		try { | ||||
| 			const canvas = document.createElement('canvas'); | ||||
|  | ||||
| 			// Choose a scale factor such that the resized image fits within a | ||||
| 			// maxWidth x maxHeight box. | ||||
| 			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; | ||||
|  | ||||
| 			const ctx = canvas.getContext('2d'); | ||||
| 			ctx.drawImage(image.image, 0, 0, canvas.width, canvas.height); | ||||
|  | ||||
| 			const blob = await new Promise<Blob>((resolve, reject) => { | ||||
| 				try { | ||||
| 					canvas.toBlob( | ||||
| 						(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, | ||||
| @@ -38,6 +76,7 @@ const resizeImage = async (options: Options) => { | ||||
| 		} catch (error) { | ||||
| 			logger.warn('Error when unlinking cached file: ', error); | ||||
| 		} | ||||
| 	} | ||||
| }; | ||||
|  | ||||
| export default resizeImage; | ||||
|   | ||||
| @@ -1,9 +1,12 @@ | ||||
|  | ||||
| import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger'; | ||||
| import { SerializableData } from '@joplin/lib/utils/ipc/types'; | ||||
| import { WebViewControl } from '../../components/ExtendedWebView'; | ||||
| import { WebViewControl } from '../../components/ExtendedWebView/types'; | ||||
| import { RefObject } from 'react'; | ||||
| 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> { | ||||
| 	public constructor(channelId: string, private webviewControl: WebViewControl|RefObject<WebViewControl>, localApi: LocalInterface) { | ||||
| @@ -19,6 +22,9 @@ export default class RNToWebViewMessenger<LocalInterface, RemoteInterface> exten | ||||
| 		// This is the case in testing environments where no WebView is available. | ||||
| 		if (!webviewControl.injectJS) return; | ||||
|  | ||||
| 		if (canUseOptimizedPostMessage) { | ||||
| 			webviewControl.postMessage(message); | ||||
| 		} else { | ||||
| 			webviewControl.injectJS(` | ||||
| 				window.dispatchEvent( | ||||
| 					new MessageEvent( | ||||
| @@ -31,11 +37,16 @@ export default class RNToWebViewMessenger<LocalInterface, RemoteInterface> exten | ||||
| 				); | ||||
| 			`); | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	public onWebViewMessage = (event: OnMessageEvent) => { | ||||
| 		if (!this.hasBeenClosed()) { | ||||
| 			if (canUseOptimizedPostMessage) { | ||||
| 				void this.onMessage(event.nativeEvent.data); | ||||
| 			} else { | ||||
| 				void this.onMessage(JSON.parse(event.nativeEvent.data)); | ||||
| 			} | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	public onWebViewLoaded = () => { | ||||
|   | ||||
| @@ -2,6 +2,14 @@ | ||||
| import RemoteMessenger from '@joplin/lib/utils/ipc/RemoteMessenger'; | ||||
| 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> { | ||||
| 	public constructor(channelId: string, localApi: LocalInterface) { | ||||
| 		super(channelId, localApi); | ||||
| @@ -24,8 +32,7 @@ export default class WebViewToRNMessenger<LocalInterface, RemoteInterface> exten | ||||
| 	}; | ||||
|  | ||||
| 	protected override postMessage(message: SerializableData): void { | ||||
| 		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 		(window as any).ReactNativeWebView.postMessage(JSON.stringify(message)); | ||||
| 		window.ReactNativeWebView.postMessage(window.ReactNativeWebView.supportsNonStringMessages ? message : JSON.stringify(message)); | ||||
| 	} | ||||
|  | ||||
| 	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 { Alert, AlertButton } from 'react-native'; | ||||
| import { Alert } from 'react-native'; | ||||
| import { DialogControl, PromptButton } from '../components/DialogManager'; | ||||
| import { RefObject } from 'react'; | ||||
| 
 | ||||
| interface Options { | ||||
| 	title: 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 => { | ||||
| 		const defaultButtons: AlertButton[] = [ | ||||
| 		const defaultButtons: PromptButton[] = [ | ||||
| 			{ | ||||
| 				text: _('OK'), | ||||
| 				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 ?? '', | ||||
| 			message, | ||||
| 			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 { openDocument } from '@joplin/react-native-saf-x'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import type FsDriverWeb from './fs-driver/fs-driver-rn.web'; | ||||
| import uuid from '@joplin/lib/uuid'; | ||||
|  | ||||
| interface SelectedDocument { | ||||
| 	type: string; | ||||
| @@ -12,10 +14,58 @@ interface SelectedDocument { | ||||
|  | ||||
| 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[] = []; | ||||
| 	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 }); | ||||
| 			if (!openDocResult) { | ||||
| 				throw new Error('User canceled document picker'); | ||||
| @@ -48,7 +98,7 @@ const pickDocument = async (multiple: boolean): Promise<SelectedDocument[]> => { | ||||
| 			}); | ||||
| 		} | ||||
| 	} catch (error) { | ||||
| 		if (DocumentPicker.isCancel(error) || error?.message?.includes('cancel')) { | ||||
| 		if (DocumentPicker?.isCancel?.(error) || error?.message?.includes('cancel')) { | ||||
| 			logger.info('user has cancelled'); | ||||
| 			return []; | ||||
| 		} else { | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user