From b07874fc5b93ec59642ee31b6852058345cbe7bf Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Wed, 13 Nov 2024 23:17:50 -0800 Subject: [PATCH 01/11] Mobile: Accessibility: Prefer modal dialogs for note and long-press menus Previously, a library that did not properly manage accessibility focus was used. This commit updates the existing web dialog logic (which uses a React Native `Modal`) to also provide long press and note re-order menus. --- .../app-mobile/components/DialogManager.tsx | 139 ++++++++++++++---- .../hooks/useOnResourceLongPress.ts | 13 +- packages/app-mobile/components/app-nav.tsx | 5 +- .../app-mobile/components/screens/Notes.tsx | 6 +- packages/app-mobile/root.tsx | 2 +- 5 files changed, 126 insertions(+), 39 deletions(-) diff --git a/packages/app-mobile/components/DialogManager.tsx b/packages/app-mobile/components/DialogManager.tsx index 30fd56a88e5..37382e5a615 100644 --- a/packages/app-mobile/components/DialogManager.tsx +++ b/packages/app-mobile/components/DialogManager.tsx @@ -1,11 +1,12 @@ 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 { Alert, Platform, StyleSheet, useWindowDimensions } from 'react-native'; +import { Button, Dialog, Divider, Portal, Surface, 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'; +import { themeStyle } from './global-style'; export interface PromptButton { text: string; @@ -17,17 +18,30 @@ interface PromptOptions { cancelable?: boolean; } +interface MenuChoice { + text: string; + id: IdType; +} + export interface DialogControl { prompt(title: string, message: string, buttons?: PromptButton[], options?: PromptOptions): void; + showMenu(title: string, choices: MenuChoice[]): Promise; } export const DialogContext = createContext(null); interface Props { + themeId: number; children: React.ReactNode; } +enum DialogType { + Prompt, + Menu, +} + interface PromptDialogData { + type: DialogType; key: string; title: string; message: string; @@ -35,49 +49,77 @@ interface PromptDialogData { onDismiss: (()=> void)|null; } -const styles = StyleSheet.create({ - dialogContainer: { - maxWidth: 400, - minWidth: '50%', - alignSelf: 'center', - }, - modalContainer: { - marginTop: 'auto', - marginBottom: 'auto', - }, -}); +const useStyles = (themeId: number) => { + const windowSize = useWindowDimensions(); + + return useMemo(() => { + const theme = themeStyle(themeId); + + return StyleSheet.create({ + dialogContainer: { + backgroundColor: theme.backgroundColor, + borderRadius: 24, + paddingTop: 24, + maxHeight: windowSize.height, + }, + modalContainer: { + marginLeft: 'auto', + marginRight: 'auto', + marginTop: 'auto', + marginBottom: 'auto', + width: Math.max(windowSize.width / 2, 400), + }, + + dialogContent: { + paddingBottom: 14, + }, + dialogActions: { + paddingBottom: 14, + }, + menuDialogActions: { + paddingTop: 4, + flexDirection: 'column', + alignItems: 'stretch', + }, + menuDialogLabel: { + textAlign: 'center', + }, + }); + }, [windowSize.width, windowSize.height, themeId]); +}; const DialogManager: React.FC = props => { const [dialogModels, setPromptDialogs] = useState([]); const nextDialogIdRef = useRef(0); const dialogControl: DialogControl = useMemo(() => { + const onDismiss = (dialog: PromptDialogData) => { + setPromptDialogs(dialogs => dialogs.filter(d => d !== dialog)); + }; + const defaultButtons = [{ text: _('OK') }]; return { prompt: (title: string, message: string, buttons: PromptButton[] = defaultButtons, options?: PromptOptions) => { - if (Platform.OS !== 'web') { + if (Platform.OS !== 'web' && Platform.OS !== 'android') { // 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 = { + type: DialogType.Prompt, key: `dialog-${nextDialogIdRef.current++}`, title, message, buttons: buttons.map(button => ({ ...button, onPress: () => { - onDismiss(); + onDismiss(dialog); button.onPress?.(); }, })), - onDismiss: cancelable ? onDismiss : null, + onDismiss: cancelable ? () => onDismiss(dialog) : null, }; setPromptDialogs(dialogs => { @@ -88,6 +130,32 @@ const DialogManager: React.FC = props => { }); } }, + showMenu: function(title: string, choices: MenuChoice[]) { + return new Promise((resolve) => { + const dismiss = () => onDismiss(dialog); + + const dialog: PromptDialogData = { + type: DialogType.Menu, + key: `menu-dialog-${nextDialogIdRef.current++}`, + title: '', + message: title, + buttons: choices.map(choice => ({ + text: choice.text, + onPress: () => { + dismiss(); + resolve(choice.id); + }, + })), + onDismiss: dismiss, + }; + setPromptDialogs(dialogs => { + return [ + ...dialogs, + dialog, + ]; + }); + }); + }, }; }, []); const dialogControlRef = useRef(dialogControl); @@ -101,6 +169,8 @@ const DialogManager: React.FC = props => { }; }, []); + const styles = useStyles(props.themeId); + const dialogComponents: React.ReactNode[] = []; for (const dialog of dialogModels) { const buttons = dialog.buttons.map((button, index) => { @@ -108,22 +178,33 @@ const DialogManager: React.FC = props => { ); }); + const titleComponent = {dialog.title}; + + const isMenu = dialog.type === DialogType.Menu; dialogComponents.push( - - {dialog.title} - - {dialog.message} + + {dialog.title ? titleComponent : null} + {dialog.message} - + {isMenu ? : null} + {buttons} - , + , ); } @@ -136,7 +217,7 @@ const DialogManager: React.FC = props => { void; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied -export default function useOnResourceLongPress(callbacks: Callbacks, dialogBoxRef: any) { +export default function useOnResourceLongPress(callbacks: Callbacks) { const { onJoplinLinkClick, onRequestEditResource } = callbacks; + const dialogManager = useContext(DialogContext); + return useCallback(async (msg: string) => { try { const resourceId = msg.split(':')[1]; @@ -42,7 +43,7 @@ export default function useOnResourceLongPress(callbacks: Callbacks, dialogBoxRe } actions.push({ text: _('Share'), id: 'share' }); - const action = await dialogs.pop({ dialogbox: dialogBoxRef.current }, name, actions); + const action = await dialogManager.showMenu(name, actions); if (action === 'open') { onJoplinLinkClick(`joplin://${resourceId}`); @@ -56,5 +57,5 @@ export default function useOnResourceLongPress(callbacks: Callbacks, dialogBoxRe logger.error('Could not handle link long press', e); void shim.showMessageBox(`An error occurred, check log for details: ${e}`); } - }, [onJoplinLinkClick, onRequestEditResource, dialogBoxRef]); + }, [onJoplinLinkClick, onRequestEditResource, dialogManager]); } diff --git a/packages/app-mobile/components/app-nav.tsx b/packages/app-mobile/components/app-nav.tsx index 9248fb71409..0c2f5ee1ab1 100644 --- a/packages/app-mobile/components/app-nav.tsx +++ b/packages/app-mobile/components/app-nav.tsx @@ -6,6 +6,7 @@ import { Component } from 'react'; import { KeyboardAvoidingView, Keyboard, Platform, View, KeyboardEvent, Dimensions, EmitterSubscription } from 'react-native'; import { AppState } from '../utils/types'; import { themeStyle } from './global-style'; +import { DialogContext } from './DialogManager'; interface State { autoCompletionBarExtraHeight: number; @@ -115,7 +116,9 @@ class AppNavComponent extends Component { behavior={Platform.OS === 'ios' ? 'padding' : null} style={style} > - + { + dialogs => + } {searchScreenLoaded && } {!notesScreenVisible && !searchScreenVisible && } diff --git a/packages/app-mobile/components/screens/Notes.tsx b/packages/app-mobile/components/screens/Notes.tsx index a3550c1608e..12d99e8021a 100644 --- a/packages/app-mobile/components/screens/Notes.tsx +++ b/packages/app-mobile/components/screens/Notes.tsx @@ -11,7 +11,6 @@ import { themeStyle } from '../global-style'; import { FolderPickerOptions, ScreenHeader } from '../ScreenHeader'; import { _ } from '@joplin/lib/locale'; import ActionButton from '../buttons/FloatingActionButton'; -const { dialogs } = require('../../utils/dialogs.js'); const DialogBox = require('react-native-dialogbox').default; import BackButtonService from '../../services/BackButtonService'; import { BaseScreenComponent } from '../base-screen'; @@ -20,6 +19,7 @@ import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/databa import { itemIsInTrash } from '@joplin/lib/services/trash'; import AccessibleView from '../accessibility/AccessibleView'; import { Dispatch } from 'redux'; +import { DialogControl } from '../DialogManager'; interface Props { dispatch: Dispatch; @@ -40,6 +40,8 @@ interface Props { selectedTagId: string; selectedSmartFilterId: string; notesParentType: string; + + dialogManager: DialogControl; } interface State { @@ -99,7 +101,7 @@ class NotesScreenComponent extends BaseScreenComponent { id: { name: 'showCompletedTodos', value: !Setting.value('showCompletedTodos') }, }); - const r = await dialogs.pop(this, Setting.settingMetadata('notes.sortOrder.field').label(), buttons); + const r = await this.props.dialogManager.showMenu(Setting.settingMetadata('notes.sortOrder.field').label(), buttons); if (!r) return; Setting.setValue(r.name, r.value); diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx index 13ed96c29dd..431dce031aa 100644 --- a/packages/app-mobile/root.tsx +++ b/packages/app-mobile/root.tsx @@ -1343,7 +1343,7 @@ class AppComponent extends React.Component { }, }, }}> - + {mainContent} From 89229d1abfea7a2b66df525e92d7c89959a85193 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Thu, 14 Nov 2024 11:37:01 -0800 Subject: [PATCH 02/11] Remove legacy dialogs --- .eslintignore | 2 +- .gitignore | 2 +- packages/app-mobile/commands/openItem.ts | 2 +- .../components/BackButtonDialogBox.ts | 25 ------ .../app-mobile/components/DialogManager.tsx | 22 ++++- .../NoteBodyViewer/NoteBodyViewer.tsx | 7 -- .../hooks/useOnResourceLongPress.ts | 2 +- .../components/ScreenHeader/index.tsx | 7 -- .../components/screens/LogScreen.tsx | 2 +- .../app-mobile/components/screens/Note.tsx | 52 ++++++------ .../app-mobile/components/screens/Notes.tsx | 7 -- .../{dropbox-login.js => dropbox-login.tsx} | 34 ++++---- .../components/screens/encryption-config.tsx | 9 +- .../app-mobile/components/screens/folder.js | 9 +- packages/app-mobile/package.json | 1 - packages/app-mobile/root.tsx | 2 +- packages/app-mobile/utils/dialogs.js | 82 ------------------- .../app-mobile/utils/makeShowMessageBox.ts | 31 ++++--- .../lib/commands/permanentlyDeleteNote.ts | 4 +- packages/lib/shim.ts | 23 +++++- yarn.lock | 13 --- 21 files changed, 109 insertions(+), 229 deletions(-) delete mode 100644 packages/app-mobile/components/BackButtonDialogBox.ts rename packages/app-mobile/components/screens/{dropbox-login.js => dropbox-login.tsx} (75%) delete mode 100644 packages/app-mobile/utils/dialogs.js diff --git a/.eslintignore b/.eslintignore index 2bbaeb1617d..3ceabd83a8b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -575,7 +575,6 @@ packages/app-mobile/commands/openNote.js packages/app-mobile/commands/scrollToHash.js packages/app-mobile/commands/util/goToNote.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/ActionButtons.js packages/app-mobile/components/CameraView/Camera/index.jest.js @@ -760,6 +759,7 @@ packages/app-mobile/components/screens/ShareManager/IncomingShareItem.js packages/app-mobile/components/screens/ShareManager/index.test.js packages/app-mobile/components/screens/ShareManager/index.js packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js +packages/app-mobile/components/screens/dropbox-login.js packages/app-mobile/components/screens/encryption-config.js packages/app-mobile/components/screens/status.js packages/app-mobile/components/side-menu-content.js diff --git a/.gitignore b/.gitignore index dc240afeed5..3aa6feaf8ba 100644 --- a/.gitignore +++ b/.gitignore @@ -552,7 +552,6 @@ packages/app-mobile/commands/openNote.js packages/app-mobile/commands/scrollToHash.js packages/app-mobile/commands/util/goToNote.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/ActionButtons.js packages/app-mobile/components/CameraView/Camera/index.jest.js @@ -737,6 +736,7 @@ packages/app-mobile/components/screens/ShareManager/IncomingShareItem.js packages/app-mobile/components/screens/ShareManager/index.test.js packages/app-mobile/components/screens/ShareManager/index.js packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js +packages/app-mobile/components/screens/dropbox-login.js packages/app-mobile/components/screens/encryption-config.js packages/app-mobile/components/screens/status.js packages/app-mobile/components/side-menu-content.js diff --git a/packages/app-mobile/commands/openItem.ts b/packages/app-mobile/commands/openItem.ts index 7520b1ba519..65e4812b48d 100644 --- a/packages/app-mobile/commands/openItem.ts +++ b/packages/app-mobile/commands/openItem.ts @@ -42,7 +42,7 @@ export const runtime = (): CommandRuntime => { } else { const errorMessage = _('Unsupported link or message: %s', link); logger.error(errorMessage); - await shim.showMessageBox(errorMessage); + await shim.showErrorDialog(errorMessage); } }, }; diff --git a/packages/app-mobile/components/BackButtonDialogBox.ts b/packages/app-mobile/components/BackButtonDialogBox.ts deleted file mode 100644 index c9558a3ac77..00000000000 --- a/packages/app-mobile/components/BackButtonDialogBox.ts +++ /dev/null @@ -1,25 +0,0 @@ -import BackButtonService from '../services/BackButtonService'; -const DialogBox = require('react-native-dialogbox').default; - -export default class BackButtonDialogBox extends DialogBox { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - public constructor(props: any) { - super(props); - - this.backHandler_ = () => { - if (this.state.isVisible) { - this.close(); - return true; - } - return false; - }; - } - - public async componentDidUpdate() { - if (this.state.isVisible) { - BackButtonService.addHandler(this.backHandler_); - } else { - BackButtonService.removeHandler(this.backHandler_); - } - } -} diff --git a/packages/app-mobile/components/DialogManager.tsx b/packages/app-mobile/components/DialogManager.tsx index 37382e5a615..080d84b486f 100644 --- a/packages/app-mobile/components/DialogManager.tsx +++ b/packages/app-mobile/components/DialogManager.tsx @@ -24,6 +24,8 @@ interface MenuChoice { } export interface DialogControl { + info(message: string): Promise; + error(message: string): Promise; prompt(title: string, message: string, buttons?: PromptButton[], options?: PromptOptions): void; showMenu(title: string, choices: MenuChoice[]): Promise; } @@ -98,7 +100,23 @@ const DialogManager: React.FC = props => { }; const defaultButtons = [{ text: _('OK') }]; - return { + const control: DialogControl = { + info: (message: string) => { + return new Promise((resolve) => { + control.prompt(_('Info'), message, [{ + text: _('OK'), + onPress: () => resolve(), + }]); + }); + }, + error: (message: string) => { + return new Promise((resolve) => { + control.prompt(_('Error'), message, [{ + text: _('OK'), + onPress: () => resolve(), + }]); + }); + }, prompt: (title: string, message: string, buttons: PromptButton[] = defaultButtons, options?: PromptOptions) => { if (Platform.OS !== 'web' && Platform.OS !== 'android') { // Alert.alert provides a more native style on iOS. @@ -157,6 +175,8 @@ const DialogManager: React.FC = props => { }); }, }; + + return control; }, []); const dialogControlRef = useRef(dialogControl); dialogControlRef.current = dialogControl; diff --git a/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx b/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx index c8ffe0c26bf..acb23328942 100644 --- a/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx +++ b/packages/app-mobile/components/NoteBodyViewer/NoteBodyViewer.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import useOnMessage, { HandleMessageCallback, OnMarkForDownloadCallback } from './hooks/useOnMessage'; import { useRef, useCallback, useState, useMemo } from 'react'; import { View, ViewStyle } from 'react-native'; -import BackButtonDialogBox from '../BackButtonDialogBox'; import ExtendedWebView from '../ExtendedWebView'; import { WebViewControl } from '../ExtendedWebView/types'; import useOnResourceLongPress from './hooks/useOnResourceLongPress'; @@ -37,7 +36,6 @@ interface Props { } export default function NoteBodyViewer(props: Props) { - const dialogBoxRef = useRef(null); const webviewRef = useRef(null); const onScroll = useCallback(async (scrollTop: number) => { @@ -49,7 +47,6 @@ export default function NoteBodyViewer(props: Props) { onJoplinLinkClick: props.onJoplinLinkClick, onRequestEditResource: props.onRequestEditResource, }, - dialogBoxRef, ); const onPostMessage = useOnMessage(props.noteBody, { @@ -101,9 +98,6 @@ export default function NoteBodyViewer(props: Props) { if (props.onLoadEnd) props.onLoadEnd(); }, [props.onLoadEnd]); - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - const BackButtonDialogBox_ = BackButtonDialogBox as any; - const { html, injectedJs } = useSource(tempDir, props.themeId); return ( @@ -119,7 +113,6 @@ export default function NoteBodyViewer(props: Props) { onLoadEnd={onLoadEnd} onMessage={onWebViewMessage} /> - ); } diff --git a/packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.ts b/packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.ts index 4937b0f2f04..5043d8b454d 100644 --- a/packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.ts +++ b/packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.ts @@ -55,7 +55,7 @@ export default function useOnResourceLongPress(callbacks: Callbacks) { } } catch (e) { logger.error('Could not handle link long press', e); - void shim.showMessageBox(`An error occurred, check log for details: ${e}`); + void shim.showErrorDialog(`An error occurred, check log for details: ${e}`); } }, [onJoplinLinkClick, onRequestEditResource, dialogManager]); } diff --git a/packages/app-mobile/components/ScreenHeader/index.tsx b/packages/app-mobile/components/ScreenHeader/index.tsx index 0afebcaaa1b..8000db3de06 100644 --- a/packages/app-mobile/components/ScreenHeader/index.tsx +++ b/packages/app-mobile/components/ScreenHeader/index.tsx @@ -10,7 +10,6 @@ import Note from '@joplin/lib/models/Note'; import Folder from '@joplin/lib/models/Folder'; import { themeStyle } from '../global-style'; import { OnValueChangedListener } from '../Dropdown'; -const DialogBox = require('react-native-dialogbox').default; import { FolderEntity } from '@joplin/lib/services/database/types'; import { State } from '@joplin/lib/reducer'; import IconButton from '../IconButton'; @@ -84,7 +83,6 @@ interface ScreenHeaderState { class ScreenHeaderComponent extends PureComponent { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied private cachedStyles: any; - public dialogbox?: typeof DialogBox; public constructor(props: ScreenHeaderProps) { super(props); this.cachedStyles = {}; @@ -645,11 +643,6 @@ class ScreenHeaderComponent extends PureComponent - { - this.dialogbox = dialogbox; - }} - /> ); } diff --git a/packages/app-mobile/components/screens/LogScreen.tsx b/packages/app-mobile/components/screens/LogScreen.tsx index 69cbf8a4955..2f184d9bce3 100644 --- a/packages/app-mobile/components/screens/LogScreen.tsx +++ b/packages/app-mobile/components/screens/LogScreen.tsx @@ -105,7 +105,7 @@ class LogScreenComponent extends BaseScreenComponent { 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). - void shim.showMessageBox(_('Error'), _('Unable to share log data. Reason: %s', e.toString())); + void shim.showErrorDialog(_('Unable to share log data. Reason: %s', e.toString())); } finally { if (fileToShare) { await shim.fsDriver().remove(fileToShare); diff --git a/packages/app-mobile/components/screens/Note.tsx b/packages/app-mobile/components/screens/Note.tsx index 05df8c27faa..447408d1ed0 100644 --- a/packages/app-mobile/components/screens/Note.tsx +++ b/packages/app-mobile/components/screens/Note.tsx @@ -32,8 +32,6 @@ import { reg } from '@joplin/lib/registry'; import ResourceFetcher from '@joplin/lib/services/ResourceFetcher'; import { BaseScreenComponent } from '../base-screen'; import { themeStyle, editorFont } from '../global-style'; -const { dialogs } = require('../../utils/dialogs.js'); -const DialogBox = require('react-native-dialogbox').default; import shared, { BaseNoteScreenComponent, Props as BaseProps } from '@joplin/lib/components/shared/note-screen-shared'; import { Asset, ImagePickerResponse, launchImageLibrary } from 'react-native-image-picker'; import SelectDateTimeDialog from '../SelectDateTimeDialog'; @@ -49,7 +47,7 @@ import { isSupportedLanguage } from '../../services/voiceTyping/vosk'; import { ChangeEvent as EditorChangeEvent, SelectionRangeChangeEvent, UndoRedoDepthChangeEvent } from '@joplin/editor/events'; import { join } from 'path'; import { Dispatch } from 'redux'; -import { RefObject } from 'react'; +import { RefObject, useContext } from 'react'; import { SelectionRange } from '../NoteEditor/types'; import { getNoteCallbackUrl } from '@joplin/lib/callbackUrlUtils'; import { AppState } from '../../utils/types'; @@ -64,6 +62,7 @@ import { ResourceInfo } from '../NoteBodyViewer/hooks/useRerenderHandler'; import getImageDimensions from '../../utils/image/getImageDimensions'; import resizeImage from '../../utils/image/resizeImage'; import { CameraResult } from '../CameraView/types'; +import { DialogContext, DialogControl } from '../DialogManager'; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied const emptyArray: any[] = []; @@ -87,6 +86,10 @@ interface Props extends BaseProps { toolbarEnabled: boolean; } +interface ComponentProps extends Props { + dialogs: DialogControl; +} + interface State { note: NoteEntity; mode: 'view'|'edit'; @@ -117,7 +120,7 @@ interface State { voiceTypingDialogShown: boolean; } -class NoteScreenComponent extends BaseScreenComponent implements BaseNoteScreenComponent { +class NoteScreenComponent extends BaseScreenComponent implements BaseNoteScreenComponent { // This isn't in this.state because we don't want changing scroll to trigger // a re-render. private lastBodyScroll: number|undefined = undefined; @@ -153,7 +156,7 @@ class NoteScreenComponent extends BaseScreenComponent implements B return { header: null }; } - public constructor(props: Props) { + public constructor(props: ComponentProps) { super(props); this.state = { @@ -206,7 +209,10 @@ class NoteScreenComponent extends BaseScreenComponent implements B const saveDialog = async () => { if (this.isModified()) { - const buttonId = await dialogs.pop(this, _('This note has been modified:'), [{ text: _('Save changes'), id: 'save' }, { text: _('Discard changes'), id: 'discard' }, { text: _('Cancel'), id: 'cancel' }]); + const buttonId = await this.props.dialogs.showMenu( + _('This note has been modified:'), + [{ text: _('Save changes'), id: 'save' }, { text: _('Discard changes'), id: 'discard' }, { text: _('Cancel'), id: 'cancel' }], + ); if (buttonId === 'cancel') return true; if (buttonId === 'save') await this.saveNoteButton_press(); @@ -269,7 +275,7 @@ class NoteScreenComponent extends BaseScreenComponent implements B try { await CommandService.instance().execute('openItem', msg); } catch (error) { - dialogs.error(this, error.message); + await this.props.dialogs.error(error.message); } }; @@ -664,14 +670,15 @@ class NoteScreenComponent extends BaseScreenComponent implements B if (canResize) { const resizeLargeImages = Setting.value('imageResizing'); if (resizeLargeImages === 'alwaysAsk') { - const userAnswer = await dialogs.pop(this, `${_('You are about to attach a large image (%dx%d pixels). Would you like to resize it down to %d pixels before attaching it?', dimensions.width, dimensions.height, maxSize)}\n\n${_('(You may disable this prompt in the options)')}`, [ - { text: _('Yes'), id: 'yes' }, - { text: _('No'), id: 'no' }, - { text: _('Cancel'), id: 'cancel' }, - ]); + const userAnswer = await this.props.dialogs.showMenu( + `${_('You are about to attach a large image (%dx%d pixels). Would you like to resize it down to %d pixels before attaching it?', dimensions.width, dimensions.height, maxSize)}\n\n${_('(You may disable this prompt in the options)')}`, [ + { text: _('Yes'), id: 'yes' }, + { text: _('No'), id: 'no' }, + { text: _('Cancel'), id: 'cancel' }, + ]); if (userAnswer === 'yes') return await saveResizedImage(); if (userAnswer === 'no') return await saveOriginalImage(); - if (userAnswer === 'cancel') return false; + if (userAnswer === 'cancel' || !userAnswer) return false; } else if (resizeLargeImages === 'alwaysResize') { return await saveResizedImage(); } @@ -759,7 +766,7 @@ class NoteScreenComponent extends BaseScreenComponent implements B if (!done) return null; } else { if (fileType === 'image' && mimeType !== 'image/svg+xml') { - dialogs.error(this, _('Unsupported image type: %s', mimeType)); + await this.props.dialogs.error(_('Unsupported image type: %s', mimeType)); return null; } else { await shim.fsDriver().copy(localFilePath, targetPath); @@ -773,7 +780,7 @@ class NoteScreenComponent extends BaseScreenComponent implements B } } catch (error) { reg.logger().warn('Could not attach file:', error); - await dialogs.error(this, error.message); + await this.props.dialogs.error(error.message); return null; } @@ -996,7 +1003,7 @@ class NoteScreenComponent extends BaseScreenComponent implements B await Linking.openURL(url); } catch (error) { this.props.dispatch({ type: 'SIDE_MENU_CLOSE' }); - await dialogs.error(this, error.message); + await this.props.dialogs.error(error.message); } } @@ -1007,7 +1014,7 @@ class NoteScreenComponent extends BaseScreenComponent implements B try { await Linking.openURL(note.source_url); } catch (error) { - await dialogs.error(this, error.message); + await this.props.dialogs.error(error.message); } } @@ -1070,7 +1077,7 @@ class NoteScreenComponent extends BaseScreenComponent implements B if (Platform.OS === 'ios') buttons.push({ text: _('Attach photo'), id: 'attachPhoto' }); buttons.push({ text: _('Take photo'), id: 'takePhoto' }); - const buttonId = await dialogs.pop(this, _('Choose an option'), buttons); + const buttonId = await this.props.dialogs.showMenu(_('Choose an option'), buttons); if (buttonId === 'takePhoto') await this.takePhoto_onPress(); if (buttonId === 'attachFile') await this.attachFile_onPress(); @@ -1631,12 +1638,6 @@ class NoteScreenComponent extends BaseScreenComponent implements B - { - this.dialogbox = dialogbox; - }} - /> {noteTagDialog} ); @@ -1648,8 +1649,9 @@ class NoteScreenComponent extends BaseScreenComponent implements B // which can cause some bugs where previously set state to another note would interfere // how the new note should be rendered const NoteScreenWrapper = (props: Props) => { + const dialogs = useContext(DialogContext); return ( - + ); }; diff --git a/packages/app-mobile/components/screens/Notes.tsx b/packages/app-mobile/components/screens/Notes.tsx index 12d99e8021a..4a4c47ad2d2 100644 --- a/packages/app-mobile/components/screens/Notes.tsx +++ b/packages/app-mobile/components/screens/Notes.tsx @@ -11,7 +11,6 @@ import { themeStyle } from '../global-style'; import { FolderPickerOptions, ScreenHeader } from '../ScreenHeader'; import { _ } from '@joplin/lib/locale'; import ActionButton from '../buttons/FloatingActionButton'; -const DialogBox = require('react-native-dialogbox').default; import BackButtonService from '../../services/BackButtonService'; import { BaseScreenComponent } from '../base-screen'; import { AppState } from '../../utils/types'; @@ -300,12 +299,6 @@ class NotesScreenComponent extends BaseScreenComponent { {actionButtonComp} - { - this.dialogbox = dialogbox; - }} - /> ); } diff --git a/packages/app-mobile/components/screens/dropbox-login.js b/packages/app-mobile/components/screens/dropbox-login.tsx similarity index 75% rename from packages/app-mobile/components/screens/dropbox-login.js rename to packages/app-mobile/components/screens/dropbox-login.tsx index ed8cd11384a..7ca5da97fc0 100644 --- a/packages/app-mobile/components/screens/dropbox-login.js +++ b/packages/app-mobile/components/screens/dropbox-login.tsx @@ -1,29 +1,29 @@ -const React = require('react'); +import * as React from 'react'; -const { View, Button, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView } = require('react-native'); +import { View, Button, Text, TextInput, TouchableOpacity, StyleSheet, ScrollView } from 'react-native'; +import { AppState } from '../../utils/types'; const { connect } = require('react-redux'); -const { ScreenHeader } = require('../ScreenHeader'); -const { _ } = require('@joplin/lib/locale'); +import { ScreenHeader } from '../ScreenHeader'; +import { _ } from '@joplin/lib/locale'; const { BaseScreenComponent } = require('../base-screen'); -const DialogBox = require('react-native-dialogbox').default; -const { dialogs } = require('../../utils/dialogs.js'); const Shared = require('@joplin/lib/components/shared/dropbox-login-shared'); -const { themeStyle } = require('../global-style'); +const shim = require('@joplin/lib/shim').default; +import { themeStyle } from '../global-style'; class DropboxLoginScreenComponent extends BaseScreenComponent { - constructor() { + public constructor() { super(); this.styles_ = {}; - this.shared_ = new Shared(this, msg => dialogs.info(this, msg), msg => dialogs.error(this, msg)); + this.shared_ = new Shared(this, (msg: string) => shim.showMessageBox(msg), (msg: string) => shim.showErrorDialog(msg)); } - UNSAFE_componentWillMount() { + public UNSAFE_componentWillMount() { this.shared_.refreshUrl(); } - styles() { + private styles() { const themeId = this.props.themeId; const theme = themeStyle(themeId); @@ -47,7 +47,7 @@ class DropboxLoginScreenComponent extends BaseScreenComponent { return this.styles_[themeId]; } - render() { + public render() { const theme = themeStyle(this.props.themeId); return ( @@ -70,21 +70,15 @@ class DropboxLoginScreenComponent extends BaseScreenComponent { {/* Add this extra padding to make sure the view is scrollable when the keyboard is visible on small screens (iPhone SE) */} - - { - this.dialogbox = dialogbox; - }} - /> ); } } -const DropboxLoginScreen = connect(state => { +const DropboxLoginScreen = connect((state: AppState) => { return { themeId: state.settings.theme, }; })(DropboxLoginScreenComponent); -module.exports = { DropboxLoginScreen }; +export default DropboxLoginScreen; diff --git a/packages/app-mobile/components/screens/encryption-config.tsx b/packages/app-mobile/components/screens/encryption-config.tsx index 0d96cc918c8..b4512113fcf 100644 --- a/packages/app-mobile/components/screens/encryption-config.tsx +++ b/packages/app-mobile/components/screens/encryption-config.tsx @@ -3,8 +3,6 @@ const { TextInput, TouchableOpacity, Linking, View, StyleSheet, Text, Button, Sc const { connect } = require('react-redux'); import ScreenHeader from '../ScreenHeader'; import { themeStyle } from '../global-style'; -const DialogBox = require('react-native-dialogbox').default; -const { dialogs } = require('../../utils/dialogs.js'); import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService'; import { _ } from '@joplin/lib/locale'; import time from '@joplin/lib/time'; @@ -13,7 +11,8 @@ import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types'; import { State } from '@joplin/lib/reducer'; import { SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils'; import { getDefaultMasterKey, setupAndDisableEncryption, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils'; -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; +import shim from '@joplin/lib/shim'; interface Props { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied @@ -35,7 +34,6 @@ const EncryptionConfigScreen = (props: Props) => { const { passwordChecks, masterPasswordKeys } = usePasswordChecker(props.masterKeys, props.activeMasterKeyId, props.masterPassword, props.passwords); const { inputPasswords, onInputPasswordChange } = useInputPasswords(props.passwords); const { inputMasterPassword, onMasterPasswordSave, onMasterPasswordChange } = useInputMasterPassword(props.masterKeys, props.activeMasterKeyId); - const dialogBoxRef = useRef(null); const mkComps = []; @@ -240,7 +238,7 @@ const EncryptionConfigScreen = (props: Props) => { const onToggleButtonClick = async () => { if (props.encryptionEnabled) { - const ok = await dialogs.confirmRef(dialogBoxRef.current, _('Disabling encryption means *all* your notes and attachments are going to be re-synchronised and sent unencrypted to the sync target. Do you wish to continue?')); + const ok = await shim.showConfirmationDialog(_('Disabling encryption means *all* your notes and attachments are going to be re-synchronised and sent unencrypted to the sync target. Do you wish to continue?')); if (!ok) return; try { @@ -312,7 +310,6 @@ const EncryptionConfigScreen = (props: Props) => { {nonExistingMasterKeySection} - ); }; diff --git a/packages/app-mobile/components/screens/folder.js b/packages/app-mobile/components/screens/folder.js index d8de092f597..a0e11166d36 100644 --- a/packages/app-mobile/components/screens/folder.js +++ b/packages/app-mobile/components/screens/folder.js @@ -6,7 +6,7 @@ const Folder = require('@joplin/lib/models/Folder').default; const BaseModel = require('@joplin/lib/BaseModel').default; const { ScreenHeader } = require('../ScreenHeader'); const { BaseScreenComponent } = require('../base-screen'); -const { dialogs } = require('../../utils/dialogs.js'); +const shim = require('@joplin/lib/shim').default; const { _ } = require('@joplin/lib/locale'); const { default: FolderPicker } = require('../FolderPicker'); const TextInput = require('../TextInput').default; @@ -73,7 +73,7 @@ class FolderScreenComponent extends BaseScreenComponent { if (folder.id && !(await Folder.canNestUnder(folder.id, folder.parent_id))) throw new Error(_('Cannot move notebook to this location')); folder = await Folder.save(folder, { userSideValidation: true }); } catch (error) { - dialogs.error(this, _('The notebook could not be saved: %s', error.message)); + shim.showErrorDialog(_('The notebook could not be saved: %s', error.message)); return; } @@ -115,11 +115,6 @@ class FolderScreenComponent extends BaseScreenComponent { /> - { - this.dialogbox = dialogbox; - }} - /> ); } diff --git a/packages/app-mobile/package.json b/packages/app-mobile/package.json index 496ef83c7a5..3491c5f66b3 100644 --- a/packages/app-mobile/package.json +++ b/packages/app-mobile/package.json @@ -49,7 +49,6 @@ "react": "18.3.1", "react-native": "0.74.1", "react-native-device-info": "10.14.0", - "react-native-dialogbox": "0.6.10", "react-native-document-picker": "9.3.0", "react-native-dropdownalert": "5.1.0", "react-native-exit-app": "2.0.0", diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx index 431dce031aa..ea396278008 100644 --- a/packages/app-mobile/root.tsx +++ b/packages/app-mobile/root.tsx @@ -64,7 +64,7 @@ import StatusScreen from './components/screens/status'; import SearchScreen from './components/screens/SearchScreen'; const { OneDriveLoginScreen } = require('./components/screens/onedrive-login.js'); import EncryptionConfigScreen from './components/screens/encryption-config'; -const { DropboxLoginScreen } = require('./components/screens/dropbox-login.js'); +import DropboxLoginScreen from './components/screens/dropbox-login.js'; import { MenuProvider } from 'react-native-popup-menu'; import SideMenu, { SideMenuPosition } from './components/SideMenu'; import SideMenuContent from './components/side-menu-content'; diff --git a/packages/app-mobile/utils/dialogs.js b/packages/app-mobile/utils/dialogs.js deleted file mode 100644 index 0d6b71f4aa5..00000000000 --- a/packages/app-mobile/utils/dialogs.js +++ /dev/null @@ -1,82 +0,0 @@ -const DialogBox = require('react-native-dialogbox').default; -const { Keyboard } = require('react-native'); - -// Add this at the bottom of the component: -// -// { this.dialogbox = dialogbox }}/> - -const dialogs = {}; - -dialogs.confirmRef = (ref, message) => { - if (!ref) throw new Error('ref is required'); - - return new Promise((resolve) => { - Keyboard.dismiss(); - - ref.confirm({ - content: message, - - ok: { - callback: () => { - resolve(true); - }, - }, - - cancel: { - callback: () => { - resolve(false); - }, - }, - }); - }); -}; - -dialogs.confirm = (parentComponent, message) => { - if (!parentComponent) throw new Error('parentComponent is required'); - if (!('dialogbox' in parentComponent)) throw new Error('A "dialogbox" component must be defined on the parent component!'); - - return dialogs.confirmRef(parentComponent.dialogbox, message); -}; - -dialogs.pop = (parentComponent, message, buttons, options = null) => { - if (!parentComponent) throw new Error('parentComponent is required'); - if (!('dialogbox' in parentComponent)) throw new Error('A "dialogbox" component must be defined on the parent component!'); - - if (!options) options = {}; - if (!('buttonFlow' in options)) options.buttonFlow = 'auto'; - - return new Promise((resolve) => { - Keyboard.dismiss(); - - const btns = []; - for (let i = 0; i < buttons.length; i++) { - btns.push({ - text: buttons[i].text, - callback: () => { - parentComponent.dialogbox.close(); - resolve(buttons[i].id); - }, - }); - } - - parentComponent.dialogbox.pop({ - content: message, - btns: btns, - buttonFlow: options.buttonFlow, - }); - }); -}; - -dialogs.error = (parentComponent, message) => { - Keyboard.dismiss(); - return parentComponent.dialogbox.alert(message); -}; - -dialogs.info = (parentComponent, message) => { - Keyboard.dismiss(); - return parentComponent.dialogbox.alert(message); -}; - -dialogs.DialogBox = DialogBox; - -module.exports = { dialogs }; diff --git a/packages/app-mobile/utils/makeShowMessageBox.ts b/packages/app-mobile/utils/makeShowMessageBox.ts index c5c053e2bf3..02990e14ccd 100644 --- a/packages/app-mobile/utils/makeShowMessageBox.ts +++ b/packages/app-mobile/utils/makeShowMessageBox.ts @@ -2,27 +2,24 @@ import { _ } from '@joplin/lib/locale'; import { Alert } from 'react-native'; import { DialogControl, PromptButton } from '../components/DialogManager'; import { RefObject } from 'react'; +import { MessageBoxType, ShowMessageBoxOptions } from '@joplin/lib/shim'; -interface Options { - title: string; - buttons: string[]; -} -const makeShowMessageBox = (dialogControl: null|RefObject) => (message: string, options: Options = null) => { +const makeShowMessageBox = (dialogControl: null|RefObject) => (message: string, options: ShowMessageBoxOptions = null) => { return new Promise(resolve => { - const defaultButtons: PromptButton[] = [ - { - text: _('OK'), - onPress: () => resolve(0), - }, - { - text: _('Cancel'), - onPress: () => resolve(1), - style: 'cancel', - }, - ]; + const okButton: PromptButton = { + text: _('OK'), + onPress: () => resolve(0), + }; + const cancelButton: PromptButton = { + text: _('Cancel'), + onPress: () => resolve(1), + style: 'cancel', + }; + const defaultConfirmButtons = [okButton, cancelButton]; + const defaultAlertButtons = [okButton]; - let buttons = defaultButtons; + let buttons = options?.type === MessageBoxType.Confirm ? defaultConfirmButtons : defaultAlertButtons; if (options?.buttons) { buttons = options.buttons.map((text, index) => { return { diff --git a/packages/lib/commands/permanentlyDeleteNote.ts b/packages/lib/commands/permanentlyDeleteNote.ts index d5459559000..1031d336380 100644 --- a/packages/lib/commands/permanentlyDeleteNote.ts +++ b/packages/lib/commands/permanentlyDeleteNote.ts @@ -1,7 +1,7 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '../services/CommandService'; import { _ } from '../locale'; import Note from '../models/Note'; -import shim from '../shim'; +import shim, { MessageBoxType } from '../shim'; export const declaration: CommandDeclaration = { name: 'permanentlyDeleteNote', @@ -21,7 +21,7 @@ export const runtime = (): CommandRuntime => { buttons: [_('Delete'), _('Cancel')], defaultId: 1, cancelId: 1, - type: 'question', + type: MessageBoxType.Confirm, }); if (result === deleteIndex) { diff --git a/packages/lib/shim.ts b/packages/lib/shim.ts index 27cf3de1366..b835674a953 100644 --- a/packages/lib/shim.ts +++ b/packages/lib/shim.ts @@ -40,6 +40,20 @@ interface AttachFileToNoteOptions { markupLanguage?: MarkupLanguage; } +export enum MessageBoxType { + Confirm = 'question', + Error = 'error', + Info = 'info', +} + +export interface ShowMessageBoxOptions { + title?: string; + buttons?: string[]; + type?: MessageBoxType; + defaultId?: number; + cancelId?: number; +} + let isTestingEnv_ = false; // We need to ensure that there's only one instance of React being used by all @@ -397,13 +411,16 @@ const shim = { // Returns the index of the button that was clicked. By default, // 0 -> OK // 1 -> Cancel - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied - showMessageBox: (_message: string, _options: any = null): Promise => { + showMessageBox: (_message: string, _options: ShowMessageBoxOptions = null): Promise => { throw new Error('Not implemented'); }, + showErrorDialog: async (message: string): Promise => { + await shim.showMessageBox(message, { type: MessageBoxType.Error }); + }, + showConfirmationDialog: async (message: string): Promise => { - return await shim.showMessageBox(message) === 0; + return await shim.showMessageBox(message, { type: MessageBoxType.Confirm }) === 0; }, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied diff --git a/yarn.lock b/yarn.lock index 830d65e3006..bea54af961e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8402,7 +8402,6 @@ __metadata: react-dom: 18.3.1 react-native: 0.74.1 react-native-device-info: 10.14.0 - react-native-dialogbox: 0.6.10 react-native-document-picker: 9.3.0 react-native-dropdownalert: 5.1.0 react-native-exit-app: 2.0.0 @@ -39434,18 +39433,6 @@ __metadata: languageName: node linkType: hard -"react-native-dialogbox@npm:0.6.10": - version: 0.6.10 - resolution: "react-native-dialogbox@npm:0.6.10" - dependencies: - prop-types: ^15.6.2 - peerDependencies: - react: "*" - react-native: ">=0.30.0" - checksum: 4163dbf7975551b905053b9df4cdbcb02116acb2aaf16e88546e0f48b82bc18af5f0fffec384dcb1f570cc35fc59804bdb7cba0a153e75cacd72201f159e7e58 - languageName: node - linkType: hard - "react-native-document-picker@npm:9.3.0": version: 9.3.0 resolution: "react-native-document-picker@npm:9.3.0" From b9930eb2a42a3413ec0541b8e8b50b3f147abc0a Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Thu, 14 Nov 2024 14:45:15 -0800 Subject: [PATCH 03/11] Scroll menu options when too many for the screen size --- .eslintignore | 5 +- .gitignore | 5 +- .../app-mobile/components/DialogManager.tsx | 251 ------------------ .../components/DialogManager/PromptDialog.tsx | 110 ++++++++ .../DialogManager/hooks/useDialogControl.ts | 99 +++++++ .../components/DialogManager/index.tsx | 84 ++++++ .../components/DialogManager/types.ts | 37 +++ .../app-mobile/utils/makeShowMessageBox.ts | 3 +- 8 files changed, 340 insertions(+), 254 deletions(-) delete mode 100644 packages/app-mobile/components/DialogManager.tsx create mode 100644 packages/app-mobile/components/DialogManager/PromptDialog.tsx create mode 100644 packages/app-mobile/components/DialogManager/hooks/useDialogControl.ts create mode 100644 packages/app-mobile/components/DialogManager/index.tsx create mode 100644 packages/app-mobile/components/DialogManager/types.ts diff --git a/.eslintignore b/.eslintignore index 3ceabd83a8b..3de567edab3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -587,7 +587,10 @@ packages/app-mobile/components/CameraView/types.js packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js packages/app-mobile/components/CameraView/utils/useBarcodeScanner.js packages/app-mobile/components/Checkbox.js -packages/app-mobile/components/DialogManager.js +packages/app-mobile/components/DialogManager/PromptDialog.js +packages/app-mobile/components/DialogManager/hooks/useDialogControl.js +packages/app-mobile/components/DialogManager/index.js +packages/app-mobile/components/DialogManager/types.js packages/app-mobile/components/DismissibleDialog.js packages/app-mobile/components/Dropdown.test.js packages/app-mobile/components/Dropdown.js diff --git a/.gitignore b/.gitignore index 3aa6feaf8ba..5aea8e11cae 100644 --- a/.gitignore +++ b/.gitignore @@ -564,7 +564,10 @@ packages/app-mobile/components/CameraView/types.js packages/app-mobile/components/CameraView/utils/fitRectIntoBounds.js packages/app-mobile/components/CameraView/utils/useBarcodeScanner.js packages/app-mobile/components/Checkbox.js -packages/app-mobile/components/DialogManager.js +packages/app-mobile/components/DialogManager/PromptDialog.js +packages/app-mobile/components/DialogManager/hooks/useDialogControl.js +packages/app-mobile/components/DialogManager/index.js +packages/app-mobile/components/DialogManager/types.js packages/app-mobile/components/DismissibleDialog.js packages/app-mobile/components/Dropdown.test.js packages/app-mobile/components/Dropdown.js diff --git a/packages/app-mobile/components/DialogManager.tsx b/packages/app-mobile/components/DialogManager.tsx deleted file mode 100644 index 080d84b486f..00000000000 --- a/packages/app-mobile/components/DialogManager.tsx +++ /dev/null @@ -1,251 +0,0 @@ -import * as React from 'react'; -import { createContext, useEffect, useMemo, useRef, useState } from 'react'; -import { Alert, Platform, StyleSheet, useWindowDimensions } from 'react-native'; -import { Button, Dialog, Divider, Portal, Surface, 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'; -import { themeStyle } from './global-style'; - -export interface PromptButton { - text: string; - onPress?: ()=> void; - style?: 'cancel'|'default'|'destructive'; -} - -interface PromptOptions { - cancelable?: boolean; -} - -interface MenuChoice { - text: string; - id: IdType; -} - -export interface DialogControl { - info(message: string): Promise; - error(message: string): Promise; - prompt(title: string, message: string, buttons?: PromptButton[], options?: PromptOptions): void; - showMenu(title: string, choices: MenuChoice[]): Promise; -} - -export const DialogContext = createContext(null); - -interface Props { - themeId: number; - children: React.ReactNode; -} - -enum DialogType { - Prompt, - Menu, -} - -interface PromptDialogData { - type: DialogType; - key: string; - title: string; - message: string; - buttons: PromptButton[]; - onDismiss: (()=> void)|null; -} - -const useStyles = (themeId: number) => { - const windowSize = useWindowDimensions(); - - return useMemo(() => { - const theme = themeStyle(themeId); - - return StyleSheet.create({ - dialogContainer: { - backgroundColor: theme.backgroundColor, - borderRadius: 24, - paddingTop: 24, - maxHeight: windowSize.height, - }, - modalContainer: { - marginLeft: 'auto', - marginRight: 'auto', - marginTop: 'auto', - marginBottom: 'auto', - width: Math.max(windowSize.width / 2, 400), - }, - - dialogContent: { - paddingBottom: 14, - }, - dialogActions: { - paddingBottom: 14, - }, - menuDialogActions: { - paddingTop: 4, - flexDirection: 'column', - alignItems: 'stretch', - }, - menuDialogLabel: { - textAlign: 'center', - }, - }); - }, [windowSize.width, windowSize.height, themeId]); -}; - -const DialogManager: React.FC = props => { - const [dialogModels, setPromptDialogs] = useState([]); - const nextDialogIdRef = useRef(0); - - const dialogControl: DialogControl = useMemo(() => { - const onDismiss = (dialog: PromptDialogData) => { - setPromptDialogs(dialogs => dialogs.filter(d => d !== dialog)); - }; - - const defaultButtons = [{ text: _('OK') }]; - const control: DialogControl = { - info: (message: string) => { - return new Promise((resolve) => { - control.prompt(_('Info'), message, [{ - text: _('OK'), - onPress: () => resolve(), - }]); - }); - }, - error: (message: string) => { - return new Promise((resolve) => { - control.prompt(_('Error'), message, [{ - text: _('OK'), - onPress: () => resolve(), - }]); - }); - }, - prompt: (title: string, message: string, buttons: PromptButton[] = defaultButtons, options?: PromptOptions) => { - if (Platform.OS !== 'web' && Platform.OS !== 'android') { - // Alert.alert provides a more native style on iOS. - Alert.alert(title, message, buttons, options); - - // Alert.alert doesn't work on web. - } else { - const cancelable = options?.cancelable ?? true; - const dialog: PromptDialogData = { - type: DialogType.Prompt, - key: `dialog-${nextDialogIdRef.current++}`, - title, - message, - buttons: buttons.map(button => ({ - ...button, - onPress: () => { - onDismiss(dialog); - button.onPress?.(); - }, - })), - onDismiss: cancelable ? () => onDismiss(dialog) : null, - }; - - setPromptDialogs(dialogs => { - return [ - ...dialogs, - dialog, - ]; - }); - } - }, - showMenu: function(title: string, choices: MenuChoice[]) { - return new Promise((resolve) => { - const dismiss = () => onDismiss(dialog); - - const dialog: PromptDialogData = { - type: DialogType.Menu, - key: `menu-dialog-${nextDialogIdRef.current++}`, - title: '', - message: title, - buttons: choices.map(choice => ({ - text: choice.text, - onPress: () => { - dismiss(); - resolve(choice.id); - }, - })), - onDismiss: dismiss, - }; - setPromptDialogs(dialogs => { - return [ - ...dialogs, - dialog, - ]; - }); - }); - }, - }; - - return control; - }, []); - const dialogControlRef = useRef(dialogControl); - dialogControlRef.current = dialogControl; - - useEffect(() => { - shim.showMessageBox = makeShowMessageBox(dialogControlRef); - - return () => { - dialogControlRef.current = null; - }; - }, []); - - const styles = useStyles(props.themeId); - - const dialogComponents: React.ReactNode[] = []; - for (const dialog of dialogModels) { - const buttons = dialog.buttons.map((button, index) => { - return ( - - ); - }); - const titleComponent = {dialog.title}; - - const isMenu = dialog.type === DialogType.Menu; - dialogComponents.push( - - - {dialog.title ? titleComponent : null} - {dialog.message} - - {isMenu ? : null} - - {buttons} - - , - ); - } - - // Web: Use a wrapper for better keyboard focus handling. - return <> - - {props.children} - - - - {dialogComponents} - - - ; -}; - -export default DialogManager; diff --git a/packages/app-mobile/components/DialogManager/PromptDialog.tsx b/packages/app-mobile/components/DialogManager/PromptDialog.tsx new file mode 100644 index 00000000000..49e50b41c0f --- /dev/null +++ b/packages/app-mobile/components/DialogManager/PromptDialog.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import { Button, Dialog, Divider, Surface, Text } from 'react-native-paper'; +import { DialogType, PromptDialogData } from './types'; +import { useWindowDimensions, StyleSheet, ScrollView } from 'react-native'; +import { useMemo } from 'react'; +import { themeStyle } from '../global-style'; + +interface Props { + dialog: PromptDialogData; + themeId: number; +} + +const buttonHeight = 40; + +const useStyles = (themeId: number, buttonCount: number, isMenu: boolean) => { + const windowSize = useWindowDimensions(); + + return useMemo(() => { + const theme = themeStyle(themeId); + + return StyleSheet.create({ + dialogContainer: { + backgroundColor: theme.backgroundColor, + borderRadius: 24, + paddingTop: 24, + maxHeight: windowSize.height, + }, + + buttonScroller: { + height: isMenu ? Math.min(buttonCount * buttonHeight, windowSize.height * 2 / 3) : buttonHeight, + }, + buttonScrollerContent: { + flexDirection: 'row', + justifyContent: 'flex-end', + flexWrap: 'wrap', + }, + actionButton: { + minHeight: buttonHeight, + }, + + dialogContent: { + paddingBottom: 14, + }, + dialogActions: { + paddingBottom: 14, + paddingTop: 4, + }, + menuDialogActions: { + flexDirection: 'column', + alignContent: 'stretch', + }, + menuDialogLabel: { + textAlign: 'center', + }, + }); + }, [windowSize.height, themeId, buttonCount, isMenu]); +}; + +const PromptDialog: React.FC = ({ dialog, themeId }) => { + const isMenu = dialog.type === DialogType.Menu; + const styles = useStyles(themeId, dialog.buttons.length, isMenu); + + const buttons = dialog.buttons.map((button, index) => { + return ( + + ); + }); + const titleComponent = {dialog.title}; + + return ( + + + {dialog.title ? titleComponent : null} + {dialog.message} + + {isMenu ? : null} + + + {buttons} + + + + ); +}; + +export default PromptDialog; diff --git a/packages/app-mobile/components/DialogManager/hooks/useDialogControl.ts b/packages/app-mobile/components/DialogManager/hooks/useDialogControl.ts new file mode 100644 index 00000000000..30b9dee245f --- /dev/null +++ b/packages/app-mobile/components/DialogManager/hooks/useDialogControl.ts @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { Alert, Platform } from 'react-native'; +import { DialogControl, DialogType, MenuChoice, PromptButton, PromptDialogData, PromptOptions } from '../types'; +import { _ } from '@joplin/lib/locale'; +import { useMemo, useRef } from 'react'; + +type SetPromptDialogs = React.Dispatch>; + +const useDialogControl = (setPromptDialogs: SetPromptDialogs) => { + const nextDialogIdRef = useRef(0); + + const dialogControl: DialogControl = useMemo(() => { + const onDismiss = (dialog: PromptDialogData) => { + setPromptDialogs(dialogs => dialogs.filter(d => d !== dialog)); + }; + + const defaultButtons = [{ text: _('OK') }]; + const control: DialogControl = { + info: (message: string) => { + return new Promise((resolve) => { + control.prompt(_('Info'), message, [{ + text: _('OK'), + onPress: () => resolve(), + }]); + }); + }, + error: (message: string) => { + return new Promise((resolve) => { + control.prompt(_('Error'), message, [{ + text: _('OK'), + onPress: () => resolve(), + }]); + }); + }, + prompt: (title: string, message: string, buttons: PromptButton[] = defaultButtons, options?: PromptOptions) => { + if (Platform.OS === 'ios') { + // Alert.alert provides a more native style on iOS. + Alert.alert(title, message, buttons, options); + + // Alert.alert doesn't work on web. + } else { + const cancelable = options?.cancelable ?? true; + const dialog: PromptDialogData = { + type: DialogType.Prompt, + key: `dialog-${nextDialogIdRef.current++}`, + title, + message, + buttons: buttons.map(button => ({ + ...button, + onPress: () => { + onDismiss(dialog); + button.onPress?.(); + }, + })), + onDismiss: cancelable ? () => onDismiss(dialog) : null, + }; + + setPromptDialogs(dialogs => { + return [ + ...dialogs, + dialog, + ]; + }); + } + }, + showMenu: function(title: string, choices: MenuChoice[]) { + return new Promise((resolve) => { + const dismiss = () => onDismiss(dialog); + + const dialog: PromptDialogData = { + type: DialogType.Menu, + key: `menu-dialog-${nextDialogIdRef.current++}`, + title: '', + message: title, + buttons: choices.map(choice => ({ + text: choice.text, + onPress: () => { + dismiss(); + resolve(choice.id); + }, + })), + onDismiss: dismiss, + }; + setPromptDialogs(dialogs => { + return [ + ...dialogs, + dialog, + ]; + }); + }); + }, + }; + + return control; + }, [setPromptDialogs]); + return dialogControl; +}; + +export default useDialogControl; diff --git a/packages/app-mobile/components/DialogManager/index.tsx b/packages/app-mobile/components/DialogManager/index.tsx new file mode 100644 index 00000000000..babe751d69e --- /dev/null +++ b/packages/app-mobile/components/DialogManager/index.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { createContext, useEffect, useMemo, useRef, useState } from 'react'; +import { StyleSheet, useWindowDimensions } from 'react-native'; +import { Portal } from 'react-native-paper'; +import Modal from '../Modal'; +import shim from '@joplin/lib/shim'; +import makeShowMessageBox from '../../utils/makeShowMessageBox'; +import { DialogControl, PromptDialogData } from './types'; +import useDialogControl from './hooks/useDialogControl'; +import PromptDialog from './PromptDialog'; + +export type { DialogControl } from './types'; +export const DialogContext = createContext(null); + +interface Props { + themeId: number; + children: React.ReactNode; +} + +const useStyles = () => { + const windowSize = useWindowDimensions(); + + return useMemo(() => { + return StyleSheet.create({ + modalContainer: { + marginLeft: 'auto', + marginRight: 'auto', + marginTop: 'auto', + marginBottom: 'auto', + width: Math.max(windowSize.width / 2, 400), + }, + }); + }, [windowSize.width]); +}; + +const DialogManager: React.FC = props => { + const [dialogModels, setPromptDialogs] = useState([]); + + const dialogControl = useDialogControl(setPromptDialogs); + const dialogControlRef = useRef(dialogControl); + dialogControlRef.current = dialogControl; + + useEffect(() => { + shim.showMessageBox = makeShowMessageBox(dialogControlRef); + + return () => { + dialogControlRef.current = null; + }; + }, []); + + const styles = useStyles(); + + const dialogComponents: React.ReactNode[] = []; + for (const dialog of dialogModels) { + dialogComponents.push( + , + ); + } + + // Web: Use a wrapper for better keyboard focus handling. + return <> + + {props.children} + + + + {dialogComponents} + + + ; +}; + +export default DialogManager; diff --git a/packages/app-mobile/components/DialogManager/types.ts b/packages/app-mobile/components/DialogManager/types.ts new file mode 100644 index 00000000000..adf6732979c --- /dev/null +++ b/packages/app-mobile/components/DialogManager/types.ts @@ -0,0 +1,37 @@ + +export interface PromptButton { + text: string; + onPress?: ()=> void; + style?: 'cancel'|'default'|'destructive'; +} + +export interface PromptOptions { + cancelable?: boolean; +} + +export interface MenuChoice { + text: string; + id: IdType; +} + +export interface DialogControl { + info(message: string): Promise; + error(message: string): Promise; + prompt(title: string, message: string, buttons?: PromptButton[], options?: PromptOptions): void; + showMenu(title: string, choices: MenuChoice[]): Promise; +} + +export enum DialogType { + Prompt, + Menu, +} + +export interface PromptDialogData { + type: DialogType; + key: string; + title: string; + message: string; + buttons: PromptButton[]; + onDismiss: (()=> void)|null; +} + diff --git a/packages/app-mobile/utils/makeShowMessageBox.ts b/packages/app-mobile/utils/makeShowMessageBox.ts index 02990e14ccd..fa84a0b464a 100644 --- a/packages/app-mobile/utils/makeShowMessageBox.ts +++ b/packages/app-mobile/utils/makeShowMessageBox.ts @@ -1,8 +1,9 @@ import { _ } from '@joplin/lib/locale'; import { Alert } from 'react-native'; -import { DialogControl, PromptButton } from '../components/DialogManager'; +import { DialogControl } from '../components/DialogManager'; import { RefObject } from 'react'; import { MessageBoxType, ShowMessageBoxOptions } from '@joplin/lib/shim'; +import { PromptButton } from '../components/DialogManager/types'; const makeShowMessageBox = (dialogControl: null|RefObject) => (message: string, options: ShowMessageBoxOptions = null) => { From 1905a37492c245dfa9f52b7a820397b43d3054e0 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Thu, 14 Nov 2024 15:07:14 -0800 Subject: [PATCH 04/11] Scroll tall dialogs --- .../components/DialogManager/PromptDialog.tsx | 49 ++++++------------- .../components/DialogManager/index.tsx | 1 + packages/app-mobile/components/Modal.tsx | 34 ++++++++++--- 3 files changed, 43 insertions(+), 41 deletions(-) diff --git a/packages/app-mobile/components/DialogManager/PromptDialog.tsx b/packages/app-mobile/components/DialogManager/PromptDialog.tsx index 49e50b41c0f..4326ddb208b 100644 --- a/packages/app-mobile/components/DialogManager/PromptDialog.tsx +++ b/packages/app-mobile/components/DialogManager/PromptDialog.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { Button, Dialog, Divider, Surface, Text } from 'react-native-paper'; import { DialogType, PromptDialogData } from './types'; -import { useWindowDimensions, StyleSheet, ScrollView } from 'react-native'; +import { StyleSheet } from 'react-native'; import { useMemo } from 'react'; import { themeStyle } from '../global-style'; @@ -10,11 +10,7 @@ interface Props { themeId: number; } -const buttonHeight = 40; - -const useStyles = (themeId: number, buttonCount: number, isMenu: boolean) => { - const windowSize = useWindowDimensions(); - +const useStyles = (themeId: number, isMenu: boolean) => { return useMemo(() => { const theme = themeStyle(themeId); @@ -23,20 +19,13 @@ const useStyles = (themeId: number, buttonCount: number, isMenu: boolean) => { backgroundColor: theme.backgroundColor, borderRadius: 24, paddingTop: 24, - maxHeight: windowSize.height, }, - buttonScroller: { - height: isMenu ? Math.min(buttonCount * buttonHeight, windowSize.height * 2 / 3) : buttonHeight, - }, buttonScrollerContent: { flexDirection: 'row', justifyContent: 'flex-end', flexWrap: 'wrap', }, - actionButton: { - minHeight: buttonHeight, - }, dialogContent: { paddingBottom: 14, @@ -44,34 +33,35 @@ const useStyles = (themeId: number, buttonCount: number, isMenu: boolean) => { dialogActions: { paddingBottom: 14, paddingTop: 4, + + ...(isMenu ? { + flexDirection: 'column', + alignItems: 'stretch', + } : {}), }, - menuDialogActions: { - flexDirection: 'column', - alignContent: 'stretch', - }, - menuDialogLabel: { - textAlign: 'center', + dialogLabel: { + textAlign: isMenu ? 'center' : undefined, }, }); - }, [windowSize.height, themeId, buttonCount, isMenu]); + }, [themeId, isMenu]); }; const PromptDialog: React.FC = ({ dialog, themeId }) => { const isMenu = dialog.type === DialogType.Menu; - const styles = useStyles(themeId, dialog.buttons.length, isMenu); + const styles = useStyles(themeId, isMenu); const buttons = dialog.buttons.map((button, index) => { return ( ); }); const titleComponent = {dialog.title}; return ( @@ -85,23 +75,14 @@ const PromptDialog: React.FC = ({ dialog, themeId }) => { {dialog.title ? titleComponent : null} {dialog.message} {isMenu ? : null} - - {buttons} - + {buttons} ); diff --git a/packages/app-mobile/components/DialogManager/index.tsx b/packages/app-mobile/components/DialogManager/index.tsx index babe751d69e..cd2425884df 100644 --- a/packages/app-mobile/components/DialogManager/index.tsx +++ b/packages/app-mobile/components/DialogManager/index.tsx @@ -69,6 +69,7 @@ const DialogManager: React.FC = props => { { @@ -29,6 +34,13 @@ const useStyles = (backgroundColor?: string) => { flexGrow: 1, flexShrink: 1, }, + modalScrollView: { + flexGrow: 1, + flexShrink: 1, + }, + modalScrollViewContent: { + minHeight: '100%', + }, }); }, [isLandscape, backgroundColor]); }; @@ -51,6 +63,7 @@ const ModalElement: React.FC = ({ children, containerStyle, backgroundColor, + scrollOverflow, ...modalProps }) => { const styles = useStyles(backgroundColor); @@ -66,18 +79,25 @@ const ModalElement: React.FC = ({ const backgroundRef = useRef(); const { onShouldBackgroundCaptureTouch, onBackgroundTouchFinished } = useBackgroundTouchListeners(modalProps.onRequestClose, backgroundRef); + const contentAndBackdrop = {content}; + // supportedOrientations: On iOS, this allows the dialog to be shown in non-portrait orientations. return ( - {content} + {scrollOverflow ? ( + {contentAndBackdrop} + ) : contentAndBackdrop} ); }; From 6f0994b6496398b41868787386a5d18046e9a641 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Thu, 14 Nov 2024 16:52:23 -0800 Subject: [PATCH 05/11] Add margin to prompt dialog (for small screens) --- packages/app-mobile/components/DialogManager/PromptDialog.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/app-mobile/components/DialogManager/PromptDialog.tsx b/packages/app-mobile/components/DialogManager/PromptDialog.tsx index 4326ddb208b..b500e484305 100644 --- a/packages/app-mobile/components/DialogManager/PromptDialog.tsx +++ b/packages/app-mobile/components/DialogManager/PromptDialog.tsx @@ -19,6 +19,8 @@ const useStyles = (themeId: number, isMenu: boolean) => { backgroundColor: theme.backgroundColor, borderRadius: 24, paddingTop: 24, + marginLeft: 4, + marginRight: 4, }, buttonScrollerContent: { From e7f687e4de08febfb96e9c08535f09284435c27a Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 15 Nov 2024 11:49:15 -0800 Subject: [PATCH 06/11] Adjust how the modal background color is applied --- packages/app-mobile/components/Modal.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/app-mobile/components/Modal.tsx b/packages/app-mobile/components/Modal.tsx index 00e6dd1eecb..5ae9d63da1e 100644 --- a/packages/app-mobile/components/Modal.tsx +++ b/packages/app-mobile/components/Modal.tsx @@ -14,7 +14,7 @@ interface ModalElementProps extends ModalProps { scrollOverflow?: boolean; } -const useStyles = (backgroundColor?: string) => { +const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) => { const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const isLandscape = windowWidth > windowHeight; return useMemo(() => { @@ -30,11 +30,17 @@ const useStyles = (backgroundColor?: string) => { return StyleSheet.create({ modalBackground: { ...backgroundPadding, - backgroundColor, flexGrow: 1, flexShrink: 1, + + // When hasScrollView, the modal background is wrapped in a ScrollView. In this case, it's + // possible to scroll content outside the background into view. To prevent the edge of the + // background from being visible, the background color is applied to the ScrollView container + // instead: + backgroundColor: hasScrollView ? null : backgroundColor, }, modalScrollView: { + backgroundColor, flexGrow: 1, flexShrink: 1, }, @@ -42,7 +48,7 @@ const useStyles = (backgroundColor?: string) => { minHeight: '100%', }, }); - }, [isLandscape, backgroundColor]); + }, [hasScrollView, isLandscape, backgroundColor]); }; const useBackgroundTouchListeners = (onRequestClose: (event: GestureResponderEvent)=> void, backdropRef: RefObject) => { @@ -66,7 +72,7 @@ const ModalElement: React.FC = ({ scrollOverflow, ...modalProps }) => { - const styles = useStyles(backgroundColor); + const styles = useStyles(scrollOverflow, backgroundColor); // contentWrapper adds padding. To allow styling the region outside of the modal // (e.g. to add a background), the content is wrapped twice. From 90b6efcb950d2da3b496cd54692551c7868ab72e Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 15 Nov 2024 11:56:31 -0800 Subject: [PATCH 07/11] Refactoring --- packages/app-mobile/components/app-nav.tsx | 5 +-- .../app-mobile/components/screens/Notes.tsx | 34 ++++++++----------- 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/packages/app-mobile/components/app-nav.tsx b/packages/app-mobile/components/app-nav.tsx index 0c2f5ee1ab1..9248fb71409 100644 --- a/packages/app-mobile/components/app-nav.tsx +++ b/packages/app-mobile/components/app-nav.tsx @@ -6,7 +6,6 @@ import { Component } from 'react'; import { KeyboardAvoidingView, Keyboard, Platform, View, KeyboardEvent, Dimensions, EmitterSubscription } from 'react-native'; import { AppState } from '../utils/types'; import { themeStyle } from './global-style'; -import { DialogContext } from './DialogManager'; interface State { autoCompletionBarExtraHeight: number; @@ -116,9 +115,7 @@ class AppNavComponent extends Component { behavior={Platform.OS === 'ios' ? 'padding' : null} style={style} > - { - dialogs => - } + {searchScreenLoaded && } {!notesScreenVisible && !searchScreenVisible && } diff --git a/packages/app-mobile/components/screens/Notes.tsx b/packages/app-mobile/components/screens/Notes.tsx index 4a4c47ad2d2..0425f332a7b 100644 --- a/packages/app-mobile/components/screens/Notes.tsx +++ b/packages/app-mobile/components/screens/Notes.tsx @@ -11,14 +11,14 @@ import { themeStyle } from '../global-style'; import { FolderPickerOptions, ScreenHeader } from '../ScreenHeader'; import { _ } from '@joplin/lib/locale'; import ActionButton from '../buttons/FloatingActionButton'; -import BackButtonService from '../../services/BackButtonService'; import { BaseScreenComponent } from '../base-screen'; import { AppState } from '../../utils/types'; import { FolderEntity, NoteEntity, TagEntity } from '@joplin/lib/services/database/types'; import { itemIsInTrash } from '@joplin/lib/services/trash'; import AccessibleView from '../accessibility/AccessibleView'; import { Dispatch } from 'redux'; -import { DialogControl } from '../DialogManager'; +import { DialogContext, DialogControl } from '../DialogManager'; +import { useContext } from 'react'; interface Props { dispatch: Dispatch; @@ -39,25 +39,24 @@ interface Props { selectedTagId: string; selectedSmartFilterId: string; notesParentType: string; - - dialogManager: DialogControl; } interface State { } -type Styles = Record; +interface ComponentProps extends Props { + dialogManager: DialogControl; +} -class NotesScreenComponent extends BaseScreenComponent { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code from before rule was applied - private dialogbox: any; +type Styles = Record; +class NotesScreenComponent extends BaseScreenComponent { private onAppStateChangeSub_: NativeEventSubscription = null; private styles_: Record = {}; private folderPickerOptions_: FolderPickerOptions; - public constructor(props: Props) { + public constructor(props: ComponentProps) { super(props); } @@ -106,14 +105,6 @@ class NotesScreenComponent extends BaseScreenComponent { Setting.setValue(r.name, r.value); }; - private backHandler = () => { - if (this.dialogbox && this.dialogbox.state && this.dialogbox.state.isVisible) { - this.dialogbox.close(); - return true; - } - return false; - }; - public styles() { if (!this.styles_) this.styles_ = {}; const themeId = this.props.themeId; @@ -133,14 +124,12 @@ class NotesScreenComponent extends BaseScreenComponent { } public async componentDidMount() { - BackButtonService.addHandler(this.backHandler); await this.refreshNotes(); this.onAppStateChangeSub_ = RNAppState.addEventListener('change', this.onAppStateChange_); } public async componentWillUnmount() { if (this.onAppStateChangeSub_) this.onAppStateChangeSub_.remove(); - BackButtonService.removeHandler(this.backHandler); } public async componentDidUpdate(prevProps: Props) { @@ -304,6 +293,11 @@ class NotesScreenComponent extends BaseScreenComponent { } } +const NotesScreenWrapper: React.FC = props => { + const dialogManager = useContext(DialogContext); + return ; +}; + const NotesScreen = connect((state: AppState) => { return { folders: state.folders, @@ -322,6 +316,6 @@ const NotesScreen = connect((state: AppState) => { noteSelectionEnabled: state.noteSelectionEnabled, notesOrder: stateUtils.notesOrder(state.settings), }; -})(NotesScreenComponent); +})(NotesScreenWrapper); export default NotesScreen; From 6abeea189c9a97c452632615d0eec0ba10f9c303 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 15 Nov 2024 12:20:43 -0800 Subject: [PATCH 08/11] Fix dialog horizontal overflow on narrow screens --- packages/app-mobile/components/DialogManager/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app-mobile/components/DialogManager/index.tsx b/packages/app-mobile/components/DialogManager/index.tsx index cd2425884df..4b49929194b 100644 --- a/packages/app-mobile/components/DialogManager/index.tsx +++ b/packages/app-mobile/components/DialogManager/index.tsx @@ -28,6 +28,7 @@ const useStyles = () => { marginTop: 'auto', marginBottom: 'auto', width: Math.max(windowSize.width / 2, 400), + maxWidth: '100%', }, }); }, [windowSize.width]); From df4110e83ba9481dcb5ec7f0d546f3a4905641bb Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 15 Nov 2024 12:39:28 -0800 Subject: [PATCH 09/11] Restore Android behavior --- .../components/DialogManager/hooks/useDialogControl.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/app-mobile/components/DialogManager/hooks/useDialogControl.ts b/packages/app-mobile/components/DialogManager/hooks/useDialogControl.ts index 30b9dee245f..8eff301e3c4 100644 --- a/packages/app-mobile/components/DialogManager/hooks/useDialogControl.ts +++ b/packages/app-mobile/components/DialogManager/hooks/useDialogControl.ts @@ -33,11 +33,10 @@ const useDialogControl = (setPromptDialogs: SetPromptDialogs) => { }); }, prompt: (title: string, message: string, buttons: PromptButton[] = defaultButtons, options?: PromptOptions) => { - if (Platform.OS === 'ios') { - // Alert.alert provides a more native style on iOS. + // Alert.alert doesn't work on web. + if (Platform.OS !== 'web') { + // Note: Alert.alert provides a more native style on iOS. Alert.alert(title, message, buttons, options); - - // Alert.alert doesn't work on web. } else { const cancelable = options?.cancelable ?? true; const dialog: PromptDialogData = { From 95d2ae731aa8078ab749d05c7e6ae0ea69a46415 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 15 Nov 2024 13:33:50 -0800 Subject: [PATCH 10/11] Fix dialog containers have scroll on Android even for short dialogs --- packages/app-mobile/components/Modal.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app-mobile/components/Modal.tsx b/packages/app-mobile/components/Modal.tsx index 5ae9d63da1e..e847add545e 100644 --- a/packages/app-mobile/components/Modal.tsx +++ b/packages/app-mobile/components/Modal.tsx @@ -45,7 +45,9 @@ const useStyles = (hasScrollView: boolean, backgroundColor: string|undefined) => flexShrink: 1, }, modalScrollViewContent: { - minHeight: '100%', + // Make the scroll view's scrolling region at least as tall as its container. + // This makes it possible to vertically center the content of scrollable modals. + flexGrow: 1, }, }); }, [hasScrollView, isLandscape, backgroundColor]); From 9a796a809ba2dd4f45eb2ad822755f2c67ec1842 Mon Sep 17 00:00:00 2001 From: Henry Heino Date: Fri, 15 Nov 2024 13:50:37 -0800 Subject: [PATCH 11/11] Restore default message box buttons on mobile --- packages/app-mobile/components/screens/dropbox-login.tsx | 8 ++++++-- packages/app-mobile/utils/makeShowMessageBox.ts | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/app-mobile/components/screens/dropbox-login.tsx b/packages/app-mobile/components/screens/dropbox-login.tsx index 7ca5da97fc0..468499e9847 100644 --- a/packages/app-mobile/components/screens/dropbox-login.tsx +++ b/packages/app-mobile/components/screens/dropbox-login.tsx @@ -7,7 +7,7 @@ import { ScreenHeader } from '../ScreenHeader'; import { _ } from '@joplin/lib/locale'; const { BaseScreenComponent } = require('../base-screen'); const Shared = require('@joplin/lib/components/shared/dropbox-login-shared'); -const shim = require('@joplin/lib/shim').default; +import shim, { MessageBoxType } from '@joplin/lib/shim'; import { themeStyle } from '../global-style'; class DropboxLoginScreenComponent extends BaseScreenComponent { @@ -16,7 +16,11 @@ class DropboxLoginScreenComponent extends BaseScreenComponent { this.styles_ = {}; - this.shared_ = new Shared(this, (msg: string) => shim.showMessageBox(msg), (msg: string) => shim.showErrorDialog(msg)); + this.shared_ = new Shared( + this, + (msg: string) => shim.showMessageBox(msg, { type: MessageBoxType.Info }), + (msg: string) => shim.showErrorDialog(msg), + ); } public UNSAFE_componentWillMount() { diff --git a/packages/app-mobile/utils/makeShowMessageBox.ts b/packages/app-mobile/utils/makeShowMessageBox.ts index fa84a0b464a..596e77b1787 100644 --- a/packages/app-mobile/utils/makeShowMessageBox.ts +++ b/packages/app-mobile/utils/makeShowMessageBox.ts @@ -20,7 +20,8 @@ const makeShowMessageBox = (dialogControl: null|RefObject) => (me const defaultConfirmButtons = [okButton, cancelButton]; const defaultAlertButtons = [okButton]; - let buttons = options?.type === MessageBoxType.Confirm ? defaultConfirmButtons : defaultAlertButtons; + const dialogType = options.type ?? MessageBoxType.Confirm; + let buttons = dialogType === MessageBoxType.Confirm ? defaultConfirmButtons : defaultAlertButtons; if (options?.buttons) { buttons = options.buttons.map((text, index) => { return {