diff --git a/.eslintignore b/.eslintignore
index 69b53a542b..145439316c 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -202,7 +202,11 @@ ReactNativeClient/lib/commands/historyForward.js
ReactNativeClient/lib/commands/synchronize.js
ReactNativeClient/lib/components/BackButtonDialogBox.js
ReactNativeClient/lib/components/CameraView.js
-ReactNativeClient/lib/components/NoteBodyViewer.js
+ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnMessage.js
+ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
+ReactNativeClient/lib/components/NoteBodyViewer/hooks/useSource.js
+ReactNativeClient/lib/components/NoteBodyViewer/NoteBodyViewer.js
+ReactNativeClient/lib/components/screens/Note.js
ReactNativeClient/lib/components/screens/UpgradeSyncTargetScreen.js
ReactNativeClient/lib/components/SelectDateTimeDialog.js
ReactNativeClient/lib/errorUtils.js
diff --git a/.gitignore b/.gitignore
index 2afdeb0dee..48bd9aae5d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -196,7 +196,11 @@ ReactNativeClient/lib/commands/historyForward.js
ReactNativeClient/lib/commands/synchronize.js
ReactNativeClient/lib/components/BackButtonDialogBox.js
ReactNativeClient/lib/components/CameraView.js
-ReactNativeClient/lib/components/NoteBodyViewer.js
+ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnMessage.js
+ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnResourceLongPress.js
+ReactNativeClient/lib/components/NoteBodyViewer/hooks/useSource.js
+ReactNativeClient/lib/components/NoteBodyViewer/NoteBodyViewer.js
+ReactNativeClient/lib/components/screens/Note.js
ReactNativeClient/lib/components/screens/UpgradeSyncTargetScreen.js
ReactNativeClient/lib/components/SelectDateTimeDialog.js
ReactNativeClient/lib/errorUtils.js
diff --git a/ReactNativeClient/MarkdownEditor/MarkdownEditor.js b/ReactNativeClient/MarkdownEditor/MarkdownEditor.js
index 99bf8dc9e8..89da45cc69 100644
--- a/ReactNativeClient/MarkdownEditor/MarkdownEditor.js
+++ b/ReactNativeClient/MarkdownEditor/MarkdownEditor.js
@@ -13,7 +13,7 @@ import {
Image,
} from 'react-native';
import { renderFormatButtons } from './renderButtons';
-import NoteBodyViewer from 'lib/components/NoteBodyViewer';
+import NoteBodyViewer from 'lib/components/NoteBodyViewer/NoteBodyViewer';
const styles = StyleSheet.create({
buttonContainer: {
diff --git a/ReactNativeClient/lib/BaseApplication.ts b/ReactNativeClient/lib/BaseApplication.ts
index 56a20894e1..15b63c1222 100644
--- a/ReactNativeClient/lib/BaseApplication.ts
+++ b/ReactNativeClient/lib/BaseApplication.ts
@@ -698,6 +698,7 @@ export default class BaseApplication {
initArgs = Object.assign(initArgs, extraFlags);
this.logger_.addTarget(TargetType.File, { path: `${profileDir}/log.txt` });
+ // this.logger_.addTarget(TargetType.Console, { level: Logger.LEVEL_DEBUG });
this.logger_.setLevel(initArgs.logLevel);
reg.setLogger(this.logger_);
diff --git a/ReactNativeClient/lib/PoorManIntervals.ts b/ReactNativeClient/lib/PoorManIntervals.ts
index 906b9bdcf1..05af91d988 100644
--- a/ReactNativeClient/lib/PoorManIntervals.ts
+++ b/ReactNativeClient/lib/PoorManIntervals.ts
@@ -1,3 +1,11 @@
+// On mobile all the setTimeout and setInterval should go through this class
+// as it will either use the native timeout/interval for short intervals or
+// the custom one for long intervals.
+
+// For custom intervals, they are triggered
+// whenever the update() function is called, and in mobile it's called for
+// example on the Redux action middleware or when the app gets focus.
+
const { time } = require('lib/time-utils.js');
type IntervalId = number;
diff --git a/ReactNativeClient/lib/components/NoteBodyViewer.tsx b/ReactNativeClient/lib/components/NoteBodyViewer.tsx
deleted file mode 100644
index f58d4c06b7..0000000000
--- a/ReactNativeClient/lib/components/NoteBodyViewer.tsx
+++ /dev/null
@@ -1,338 +0,0 @@
-import Setting from 'lib/models/Setting';
-import shim from 'lib/shim';
-
-const Async = require('react-async').default;
-const React = require('react');
-const Component = React.Component;
-const { Platform, View, Text, ToastAndroid } = require('react-native');
-const { WebView } = require('react-native-webview');
-const { themeStyle } = require('lib/components/global-style.js');
-const BackButtonDialogBox = require('lib/components/BackButtonDialogBox').default;
-const { _ } = require('lib/locale.js');
-const { reg } = require('lib/registry.js');
-const { assetsToHeaders } = require('lib/joplin-renderer');
-const shared = require('lib/components/shared/note-screen-shared.js');
-const markupLanguageUtils = require('lib/markupLanguageUtils');
-const { dialogs } = require('lib/dialogs.js');
-const Resource = require('lib/models/Resource.js');
-const Share = require('react-native-share').default;
-
-export default class NoteBodyViewer extends Component {
-
- private forceUpdate_:boolean = false;
- private isMounted_:boolean = false;
- private markupToHtml_:any;
-
- constructor() {
- super();
-
- this.state = {
- resources: {},
- webViewLoaded: false,
- bodyHtml: '',
- };
-
- this.markupToHtml_ = markupLanguageUtils.newMarkupToHtml();
-
- this.reloadNote = this.reloadNote.bind(this);
- this.watchFn = this.watchFn.bind(this);
- }
-
- componentDidMount() {
- this.isMounted_ = true;
- }
-
- componentWillUnmount() {
- this.markupToHtml_ = null;
- this.isMounted_ = false;
- }
-
- async reloadNote() {
- this.forceUpdate_ = false;
-
- const note = this.props.note;
- const theme = themeStyle(this.props.themeId);
-
- const bodyToRender = note ? note.body : '';
-
- const mdOptions = {
- onResourceLoaded: () => {
- if (this.resourceLoadedTimeoutId_) {
- shim.clearTimeout(this.resourceLoadedTimeoutId_);
- this.resourceLoadedTimeoutId_ = null;
- }
-
- this.resourceLoadedTimeoutId_ = shim.setTimeout(() => {
- this.resourceLoadedTimeoutId_ = null;
- this.forceUpdate();
- }, 100);
- },
- highlightedKeywords: this.props.highlightedKeywords,
- resources: this.props.noteResources,
- codeTheme: theme.codeThemeCss,
- postMessageSyntax: 'window.joplinPostMessage_',
- enableLongPress: shim.mobilePlatform() === 'android', // On iOS, there's already a built-on open/share menu
- longPressDelay: 500, // TODO use system value
- };
-
- const result = await this.markupToHtml_.render(
- note.markup_language,
- bodyToRender,
- {
- bodyPaddingTop: '.8em', // Extra top padding on the rendered MD so it doesn't touch the border
- bodyPaddingBottom: this.props.paddingBottom, // Extra bottom padding to make it possible to scroll past the action button (so that it doesn't overlap the text)
- ...this.props.webViewStyle,
- },
- mdOptions
- );
- let html = result.html;
-
- const resourceDownloadMode = Setting.value('sync.resourceDownloadMode');
-
- const injectedJs = [];
- injectedJs.push('try {');
- injectedJs.push(shim.injectedJs('webviewLib'));
- // Note that this postMessage function accepts two arguments, for compatibility with the desktop version, but
- // the ReactNativeWebView actually supports only one, so the second arg is ignored (and currently not needed for the mobile app).
- injectedJs.push('window.joplinPostMessage_ = (msg, args) => { return window.ReactNativeWebView.postMessage(msg); };');
- injectedJs.push('webviewLib.initialize({ postMessage: msg => { return window.ReactNativeWebView.postMessage(msg); } });');
- injectedJs.push(`
- const readyStateCheckInterval = setInterval(function() {
- if (document.readyState === "complete") {
- clearInterval(readyStateCheckInterval);
- if ("${resourceDownloadMode}" === "manual") webviewLib.setupResourceManualDownload();
- const hash = "${this.props.noteHash}";
- // Gives it a bit of time before scrolling to the anchor
- // so that images are loaded.
- if (hash) {
- setTimeout(() => {
- const e = document.getElementById(hash);
- if (!e) {
- console.warn('Cannot find hash', hash);
- return;
- }
- e.scrollIntoView();
- }, 500);
- }
- }
- }, 10);
- `);
- injectedJs.push('} catch (e) {');
- injectedJs.push(' window.ReactNativeWebView.postMessage("error:" + e.message + ": " + JSON.stringify(e))');
- injectedJs.push(' true;');
- injectedJs.push('}');
- injectedJs.push('true;');
-
- html =
- `
-
-
-
-
- ${assetsToHeaders(result.pluginAssets, { asHtml: true })}
-
-
- ${html}
-
-
- `;
-
- const tempFile = `${Setting.value('resourceDir')}/NoteBodyViewer.html`
- await shim.fsDriver().writeFile(tempFile, html, 'utf8');
-
- // On iOS scalesPageToFit work like this:
- //
- // Find the widest image, resize it *and everything else* by x% so that
- // the image fits within the viewport. The problem is that it means if there's
- // a large image, everything is going to be scaled to a very small size, making
- // the text unreadable.
- //
- // On Android:
- //
- // Find the widest elements and scale them (and them only) to fit within the viewport
- // It means it's going to scale large images, but the text will remain at the normal
- // size.
- //
- // That means we can use scalesPageToFix on Android but not on iOS.
- // The weird thing is that on iOS, scalesPageToFix=false along with a CSS
- // rule "img { max-width: 100% }", works like scalesPageToFix=true on Android.
- // So we use scalesPageToFix=false on iOS along with that CSS rule.
-
- // `baseUrl` is where the images will be loaded from. So images must use a path relative to resourceDir.
- return {
- source: {
- // html: html,
- uri: 'file://' + tempFile,
- baseUrl: `file://${Setting.value('resourceDir')}/`,
- },
- injectedJs: injectedJs,
- };
- }
-
- onLoadEnd() {
- shim.setTimeout(() => {
- if (this.props.onLoadEnd) this.props.onLoadEnd();
- }, 100);
-
- if (this.state.webViewLoaded) return;
-
- // Need to display after a delay to avoid a white flash before
- // the content is displayed.
- shim.setTimeout(() => {
- if (!this.isMounted_) return;
- this.setState({ webViewLoaded: true });
- }, 100);
- }
-
- shouldComponentUpdate(nextProps:any, nextState:any) {
- const safeGetNoteProp = (props:any, propName:string) => {
- if (!props) return null;
- if (!props.note) return null;
- return props.note[propName];
- };
-
- // To address https://github.com/laurent22/joplin/issues/433
- // If a checkbox in a note is ticked, the body changes, which normally would trigger a re-render
- // of this component, which has the unfortunate side effect of making the view scroll back to the top.
- // This re-rendering however is uncessary since the component is already visually updated via JS.
- // So here, if the note has not changed, we prevent the component from updating.
- // This fixes the above issue. A drawback of this is if the note is updated via sync, this change
- // will not be displayed immediately.
- const currentNoteId = safeGetNoteProp(this.props, 'id');
- const nextNoteId = safeGetNoteProp(nextProps, 'id');
-
- if (currentNoteId !== nextNoteId || nextState.webViewLoaded !== this.state.webViewLoaded) return true;
-
- // If the length of the body has changed, then it's something other than a checkbox that has changed,
- // for example a resource that has been attached to the note while in View mode. In that case, update.
- return (`${safeGetNoteProp(this.props, 'body')}`).length !== (`${safeGetNoteProp(nextProps, 'body')}`).length;
- }
-
- rebuildMd() {
- this.forceUpdate_ = true;
- this.forceUpdate();
- }
-
- watchFn() {
- // react-async will not fetch the data again after the first render
- // so we use this watchFn function to force it to reload in certain
- // cases. It is used in particular when re-rendering the note when
- // a resource has been downloaded in auto mode.
- return this.forceUpdate_;
- }
-
- async onResourceLongPress(msg:string) {
- try {
- const resourceId = msg.split(':')[1];
- const resource = await Resource.load(resourceId);
- const name = resource.title ? resource.title : resource.file_name;
-
- const action = await dialogs.pop(this, name, [
- { text: _('Open'), id: 'open' },
- { text: _('Share'), id: 'share' },
- ]);
-
- if (action === 'open') {
- this.props.onJoplinLinkClick(`joplin://${resourceId}`);
- } else if (action === 'share') {
- const filename = resource.file_name ?
- `${resource.file_name}.${resource.file_extension}` :
- resource.title;
- const targetPath = `${Setting.value('resourceDir')}/${filename}`;
-
- await shim.fsDriver().copy(Resource.fullPath(resource), targetPath);
-
- await Share.open({
- type: resource.mime,
- filename: resource.title,
- url: `file://${targetPath}`,
- failOnCancel: false,
- });
-
- await shim.fsDriver().remove(targetPath);
- }
- } catch (e) {
- reg.logger().error('Could not handle link long press', e);
- ToastAndroid.show('An error occurred, check log for details', ToastAndroid.SHORT);
- }
- }
-
- render() {
- // Note: useWebKit={false} is needed to go around this bug:
- // https://github.com/react-native-community/react-native-webview/issues/376
- // However, if we add the tag as described there, it is no longer necessary and WebKit can be used!
- // https://github.com/react-native-community/react-native-webview/issues/312#issuecomment-501991406
- //
- // However, on iOS, due to the bug below, we cannot use WebKit:
- // https://github.com/react-native-community/react-native-webview/issues/312#issuecomment-503754654
-
-
- const webViewStyle:any = { backgroundColor: this.props.webViewStyle.backgroundColor };
- // On iOS, the onLoadEnd() event is never fired so always
- // display the webview (don't do the little trick
- // to avoid the white flash).
- if (Platform.OS !== 'ios') {
- webViewStyle.opacity = this.state.webViewLoaded ? 1 : 0.01;
- }
-
- const useWebkit = true; //Platform.OS !== 'ios'
-
- return (
-
-
- {(args:any) => {
- const { data, error, isPending } = args;
-
- if (error) {
- console.error(error);
- return {error.message};
- }
-
- if (isPending) return null;
-
- return (
- this.onLoadEnd()}
- onError={() => reg.logger().error('WebView error')}
- onMessage={(event:any) => {
- // Since RN 58 (or 59) messages are now escaped twice???
- let msg = unescape(unescape(event.nativeEvent.data));
-
- console.info('Got IPC message: ', msg);
-
- if (msg.indexOf('checkboxclick:') === 0) {
- const newBody = shared.toggleCheckbox(msg, this.props.note.body);
- if (this.props.onCheckboxChange) this.props.onCheckboxChange(newBody);
- } else if (msg.indexOf('markForDownload:') === 0) {
- const splittedMsg = msg.split(':');
- const resourceId = splittedMsg[1];
- if (this.props.onMarkForDownload) this.props.onMarkForDownload({ resourceId: resourceId });
- } else if (msg.startsWith('longclick:')) {
- this.onResourceLongPress(msg);
- } else if (msg.startsWith('joplin:')) {
- this.props.onJoplinLinkClick(msg);
- } else if (msg.startsWith('error:')) {
- console.error('Webview injected script error: ' + msg);
- }
- }}
- />
- );
- }}
-
- {
- this.dialogbox = dialogbox;
- }}
- />
-
- );
- }
-}
diff --git a/ReactNativeClient/lib/components/NoteBodyViewer/NoteBodyViewer.tsx b/ReactNativeClient/lib/components/NoteBodyViewer/NoteBodyViewer.tsx
new file mode 100644
index 0000000000..4b8acc65c8
--- /dev/null
+++ b/ReactNativeClient/lib/components/NoteBodyViewer/NoteBodyViewer.tsx
@@ -0,0 +1,111 @@
+import { useRef, useMemo, useCallback } from 'react';
+
+import Setting from 'lib/models/Setting';
+import useSource from './hooks/useSource';
+import useOnMessage from './hooks/useOnMessage';
+import useOnResourceLongPress from './hooks/useOnResourceLongPress';
+
+const React = require('react');
+const { View } = require('react-native');
+const { WebView } = require('react-native-webview');
+const { themeStyle } = require('lib/components/global-style.js');
+const BackButtonDialogBox = require('lib/components/BackButtonDialogBox').default;
+const { reg } = require('lib/registry.js');
+
+interface Props {
+ themeId: number,
+ style: any,
+ noteBody: string,
+ noteMarkupLanguage: number,
+ highlightedKeywords: string[],
+ noteResources: any,
+ paddingBottom: number,
+ noteHash: string,
+ onJoplinLinkClick: Function,
+ onCheckboxChange?: Function,
+ onMarkForDownload?: Function,
+ onLoadEnd?: Function,
+}
+
+export default function NoteBodyViewer(props:Props) {
+ const theme = themeStyle(props.themeId);
+
+ const webViewStyle:any = useMemo(() => {
+ return { backgroundColor: theme.backgroundColor };
+ }, [theme.backgroundColor]);
+
+ const dialogBoxRef = useRef(null);
+
+ const { source, injectedJs } = useSource(
+ props.noteBody,
+ props.noteMarkupLanguage,
+ props.themeId,
+ props.highlightedKeywords,
+ props.noteResources,
+ props.paddingBottom,
+ props.noteHash
+ );
+
+ const onResourceLongPress = useOnResourceLongPress(
+ props.onJoplinLinkClick,
+ dialogBoxRef
+ );
+
+ const onMessage = useOnMessage(
+ props.onCheckboxChange,
+ props.noteBody,
+ props.onMarkForDownload,
+ props.onJoplinLinkClick,
+ onResourceLongPress
+ );
+
+ const onLoadEnd = useCallback(() => {
+ if (props.onLoadEnd) props.onLoadEnd();
+ }, [props.onLoadEnd]);
+
+ function onError() {
+ reg.logger().error('WebView error')
+ }
+
+ // On iOS scalesPageToFit work like this:
+ //
+ // Find the widest image, resize it *and everything else* by x% so that
+ // the image fits within the viewport. The problem is that it means if there's
+ // a large image, everything is going to be scaled to a very small size, making
+ // the text unreadable.
+ //
+ // On Android:
+ //
+ // Find the widest elements and scale them (and them only) to fit within the viewport
+ // It means it's going to scale large images, but the text will remain at the normal
+ // size.
+ //
+ // That means we can use scalesPageToFix on Android but not on iOS.
+ // The weird thing is that on iOS, scalesPageToFix=false along with a CSS
+ // rule "img { max-width: 100% }", works like scalesPageToFix=true on Android.
+ // So we use scalesPageToFix=false on iOS along with that CSS rule.
+ //
+ // 2020-10-15: As we've now fully switched to WebKit for iOS (useWebKit=true) and
+ // since the WebView package went through many versions it's possible that
+ // the above no longer applies.
+
+ return (
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnMessage.ts b/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnMessage.ts
new file mode 100644
index 0000000000..410f3525b9
--- /dev/null
+++ b/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnMessage.ts
@@ -0,0 +1,26 @@
+import { useCallback } from 'react';
+const shared = require('lib/components/shared/note-screen-shared');
+
+export default function useOnMessage(onCheckboxChange:Function, noteBody:string, onMarkForDownload:Function, onJoplinLinkClick:Function, onResourceLongPress:Function) {
+ return useCallback((event:any) => {
+ // Since RN 58 (or 59) messages are now escaped twice???
+ let msg = unescape(unescape(event.nativeEvent.data));
+
+ console.info('Got IPC message: ', msg);
+
+ if (msg.indexOf('checkboxclick:') === 0) {
+ const newBody = shared.toggleCheckbox(msg, noteBody);
+ if (onCheckboxChange) onCheckboxChange(newBody);
+ } else if (msg.indexOf('markForDownload:') === 0) {
+ const splittedMsg = msg.split(':');
+ const resourceId = splittedMsg[1];
+ if (onMarkForDownload) onMarkForDownload({ resourceId: resourceId });
+ } else if (msg.startsWith('longclick:')) {
+ onResourceLongPress(msg);
+ } else if (msg.startsWith('joplin:')) {
+ onJoplinLinkClick(msg);
+ } else if (msg.startsWith('error:')) {
+ console.error('Webview injected script error: ' + msg);
+ }
+ }, [onCheckboxChange, noteBody, onMarkForDownload, onJoplinLinkClick, onResourceLongPress]);
+}
\ No newline at end of file
diff --git a/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnResourceLongPress.ts b/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnResourceLongPress.ts
new file mode 100644
index 0000000000..e92dc71989
--- /dev/null
+++ b/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useOnResourceLongPress.ts
@@ -0,0 +1,48 @@
+import { useCallback } from 'react';
+import Setting from 'lib/models/Setting';
+import shim from 'lib/shim';
+
+const { ToastAndroid } = require('react-native');
+const { _ } = require('lib/locale.js');
+const { reg } = require('lib/registry.js');
+const { dialogs } = require('lib/dialogs.js');
+const Resource = require('lib/models/Resource.js');
+const Share = require('react-native-share').default;
+
+export default function onResourceLongPress(onJoplinLinkClick:Function, dialogBoxRef:any) {
+ return useCallback(async (msg:string) => {
+ try {
+ const resourceId = msg.split(':')[1];
+ const resource = await Resource.load(resourceId);
+ const name = resource.title ? resource.title : resource.file_name;
+
+ const action = await dialogs.pop({ dialogbox: dialogBoxRef.current }, name, [
+ { text: _('Open'), id: 'open' },
+ { text: _('Share'), id: 'share' },
+ ]);
+
+ if (action === 'open') {
+ onJoplinLinkClick(`joplin://${resourceId}`);
+ } else if (action === 'share') {
+ const filename = resource.file_name ?
+ `${resource.file_name}.${resource.file_extension}` :
+ resource.title;
+ const targetPath = `${Setting.value('resourceDir')}/${filename}`;
+
+ await shim.fsDriver().copy(Resource.fullPath(resource), targetPath);
+
+ await Share.open({
+ type: resource.mime,
+ filename: resource.title,
+ url: `file://${targetPath}`,
+ failOnCancel: false,
+ });
+
+ await shim.fsDriver().remove(targetPath);
+ }
+ } catch (e) {
+ reg.logger().error('Could not handle link long press', e);
+ ToastAndroid.show('An error occurred, check log for details', ToastAndroid.SHORT);
+ }
+ }, [onJoplinLinkClick]);
+}
diff --git a/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useSource.ts b/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useSource.ts
new file mode 100644
index 0000000000..6e962f5018
--- /dev/null
+++ b/ReactNativeClient/lib/components/NoteBodyViewer/hooks/useSource.ts
@@ -0,0 +1,152 @@
+import { useEffect, useState, useMemo } from 'react';
+import shim from 'lib/shim';
+import Setting from 'lib/models/Setting';
+const { themeStyle } = require('lib/components/global-style.js');
+const markupLanguageUtils = require('lib/markupLanguageUtils');
+const { assetsToHeaders } = require('lib/joplin-renderer');
+
+interface Source {
+ uri: string,
+ baseUrl: string,
+}
+
+interface UseSourceResult {
+ source: Source,
+ injectedJs: string[],
+}
+
+let markupToHtml_:any = null;
+
+function markupToHtml() {
+ if (markupToHtml_) return markupToHtml_;
+ markupToHtml_ = markupLanguageUtils.newMarkupToHtml();
+ return markupToHtml_;
+}
+
+export default function useSource(noteBody:string, noteMarkupLanguage:number, themeId:number, highlightedKeywords:string[], noteResources:any, paddingBottom:number, noteHash:string):UseSourceResult {
+ const [source, setSource] = useState