You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Mobile: Implement plugin screen redesign (#10465)
This commit is contained in:
		| @@ -586,6 +586,8 @@ packages/app-mobile/components/base-screen.js | ||||
| packages/app-mobile/components/biometrics/BiometricPopup.js | ||||
| packages/app-mobile/components/biometrics/biometricAuthenticate.js | ||||
| packages/app-mobile/components/biometrics/sensorInfo.js | ||||
| packages/app-mobile/components/buttons/TextButton.js | ||||
| packages/app-mobile/components/buttons/index.js | ||||
| packages/app-mobile/components/getResponsiveValue.test.js | ||||
| packages/app-mobile/components/getResponsiveValue.js | ||||
| packages/app-mobile/components/global-style.js | ||||
| @@ -610,19 +612,28 @@ packages/app-mobile/components/screens/ConfigScreen/SettingsButton.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/ActionButton.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChips.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginTitle.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/StyledChip.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.installed.test.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.search.test.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginToggle.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginUploadButton.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.test.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/ActionButton.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButton.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/pluginServiceSetup.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/utils/openWebsiteForPlugin.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/types.js | ||||
| packages/app-mobile/components/screens/JoplinCloudLoginScreen.js | ||||
| packages/app-mobile/components/screens/LogScreen.js | ||||
|   | ||||
							
								
								
									
										21
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										21
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -565,6 +565,8 @@ packages/app-mobile/components/base-screen.js | ||||
| packages/app-mobile/components/biometrics/BiometricPopup.js | ||||
| packages/app-mobile/components/biometrics/biometricAuthenticate.js | ||||
| packages/app-mobile/components/biometrics/sensorInfo.js | ||||
| packages/app-mobile/components/buttons/TextButton.js | ||||
| packages/app-mobile/components/buttons/index.js | ||||
| packages/app-mobile/components/getResponsiveValue.test.js | ||||
| packages/app-mobile/components/getResponsiveValue.js | ||||
| packages/app-mobile/components/global-style.js | ||||
| @@ -589,19 +591,28 @@ packages/app-mobile/components/screens/ConfigScreen/SettingsButton.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/SettingsToggle.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/EnablePluginSupportPage.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/ActionButton.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginInfoButton.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/InstalledPluginBox.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginChips.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/PluginTitle.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/StyledChip.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginBox/index.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.test.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginInfoModal.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.installed.test.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.search.test.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginStates.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginToggle.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/PluginUploadButton.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.test.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/SearchPlugins.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/ActionButton.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/buttons/InstallButton.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/WrappedPluginStates.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/mockRepositoryApiConstructor.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/newRepoApi.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/testUtils/pluginServiceSetup.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/utils/openWebsiteForPlugin.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginCallbacks.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/utils/usePluginItem.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useRepoApi.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/plugins/utils/useUpdateState.js | ||||
| packages/app-mobile/components/screens/ConfigScreen/types.js | ||||
| packages/app-mobile/components/screens/JoplinCloudLoginScreen.js | ||||
| packages/app-mobile/components/screens/LogScreen.js | ||||
|   | ||||
| @@ -38,6 +38,7 @@ interface Props { | ||||
| function manifestToItem(manifest: PluginManifest): PluginItem { | ||||
| 	return { | ||||
| 		manifest: manifest, | ||||
| 		installed: true, | ||||
| 		enabled: true, | ||||
| 		deleted: false, | ||||
| 		devMode: false, | ||||
|   | ||||
| @@ -83,6 +83,7 @@ function usePluginItems(plugins: Plugins, settings: PluginSettings): PluginItem[ | ||||
|  | ||||
| 			output.push({ | ||||
| 				manifest: plugin.manifest, | ||||
| 				installed: true, | ||||
| 				enabled: setting.enabled, | ||||
| 				deleted: setting.deleted, | ||||
| 				devMode: plugin.devMode, | ||||
|   | ||||
| @@ -6,20 +6,32 @@ import { themeStyle } from './global-style'; | ||||
| import Modal from './Modal'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
|  | ||||
| export enum DialogSize { | ||||
| 	Small = 'small', | ||||
|  | ||||
| 	// Ideal for panels and dialogs that should be fullscreen even on large devices | ||||
| 	Large = 'large', | ||||
| } | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| 	visible: boolean; | ||||
| 	onDismiss: ()=> void; | ||||
| 	containerStyle?: ViewStyle; | ||||
| 	children: React.ReactNode; | ||||
|  | ||||
| 	size: DialogSize; | ||||
| } | ||||
|  | ||||
| const useStyles = (themeId: number, containerStyle: ViewStyle) => { | ||||
| const useStyles = (themeId: number, containerStyle: ViewStyle, size: DialogSize) => { | ||||
| 	const windowSize = useWindowDimensions(); | ||||
|  | ||||
| 	return useMemo(() => { | ||||
| 		const theme = themeStyle(themeId); | ||||
|  | ||||
| 		const maxWidth = size === DialogSize.Large ? Infinity : 500; | ||||
| 		const maxHeight = size === DialogSize.Large ? Infinity : 700; | ||||
|  | ||||
| 		return StyleSheet.create({ | ||||
| 			webView: { | ||||
| 				backgroundColor: 'transparent', | ||||
| @@ -38,8 +50,10 @@ const useStyles = (themeId: number, containerStyle: ViewStyle) => { | ||||
| 				borderRadius: 12, | ||||
| 				padding: 10, | ||||
|  | ||||
| 				height: windowSize.height * 0.9, | ||||
| 				width: windowSize.width * 0.97, | ||||
| 				// Use Math.min with width and height -- the maxWidth and maxHeight style | ||||
| 				// properties don't seem to limit the size for this. | ||||
| 				height: Math.min(maxHeight, windowSize.height * 0.9), | ||||
| 				width: Math.min(maxWidth, windowSize.width * 0.97), | ||||
| 				flexShrink: 1, | ||||
|  | ||||
| 				// Center | ||||
| @@ -56,11 +70,11 @@ const useStyles = (themeId: number, containerStyle: ViewStyle) => { | ||||
| 				flexGrow: 1, | ||||
| 			}, | ||||
| 		}); | ||||
| 	}, [themeId, windowSize.width, windowSize.height, containerStyle]); | ||||
| 	}, [themeId, windowSize.width, windowSize.height, containerStyle, size]); | ||||
| }; | ||||
|  | ||||
| const DismissibleDialog: React.FC<Props> = props => { | ||||
| 	const styles = useStyles(props.themeId, props.containerStyle); | ||||
| 	const styles = useStyles(props.themeId, props.containerStyle, props.size); | ||||
|  | ||||
| 	const closeButton = ( | ||||
| 		<View style={styles.closeButtonContainer}> | ||||
|   | ||||
							
								
								
									
										81
									
								
								packages/app-mobile/components/buttons/TextButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										81
									
								
								packages/app-mobile/components/buttons/TextButton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,81 @@ | ||||
| import * as React from 'react'; | ||||
| import { ReactNode, useMemo } from 'react'; | ||||
| import { themeStyle } from '../global-style'; | ||||
| import { Button, ButtonProps } from 'react-native-paper'; | ||||
| import { connect } from 'react-redux'; | ||||
| import { AppState } from '../../utils/types'; | ||||
|  | ||||
| export enum ButtonType { | ||||
| 	Primary, | ||||
| 	Secondary, | ||||
| 	Delete, | ||||
| 	Link, | ||||
| } | ||||
|  | ||||
| interface Props extends Omit<ButtonProps, 'item'|'onPress'|'children'> { | ||||
| 	themeId: number; | ||||
| 	type: ButtonType; | ||||
| 	onPress: ()=> void; | ||||
| 	children: ReactNode; | ||||
| } | ||||
|  | ||||
| export type TextButtonProps = Omit<Props, 'themeId'>; | ||||
|  | ||||
| const useStyles = ({ themeId }: Props) => { | ||||
| 	return useMemo(() => { | ||||
| 		const theme = themeStyle(themeId); | ||||
|  | ||||
| 		const themeOverride = { | ||||
| 			secondaryButton: { | ||||
| 				colors: { | ||||
| 					primary: theme.color4, | ||||
| 					outline: theme.color4, | ||||
| 				}, | ||||
| 			}, | ||||
| 			deleteButton: { | ||||
| 				colors: { | ||||
| 					primary: theme.destructiveColor, | ||||
| 					outline: theme.destructiveColor, | ||||
| 				}, | ||||
| 			}, | ||||
| 			primaryButton: { }, | ||||
| 		}; | ||||
|  | ||||
| 		return { themeOverride }; | ||||
| 	}, [themeId]); | ||||
| }; | ||||
|  | ||||
| const TextButton: React.FC<Props> = props => { | ||||
| 	const { themeOverride } = useStyles(props); | ||||
|  | ||||
| 	let mode: ButtonProps['mode']; | ||||
| 	let theme: ButtonProps['theme']; | ||||
|  | ||||
| 	if (props.type === ButtonType.Primary) { | ||||
| 		theme = themeOverride.primaryButton; | ||||
| 		mode = 'contained'; | ||||
| 	} else if (props.type === ButtonType.Secondary) { | ||||
| 		theme = themeOverride.secondaryButton; | ||||
| 		mode = 'outlined'; | ||||
| 	} else if (props.type === ButtonType.Delete) { | ||||
| 		theme = themeOverride.deleteButton; | ||||
| 		mode = 'outlined'; | ||||
| 	} else if (props.type === ButtonType.Link) { | ||||
| 		theme = themeOverride.secondaryButton; | ||||
| 		mode = 'text'; | ||||
| 	} else { | ||||
| 		const exhaustivenessCheck: never = props.type; | ||||
| 		return exhaustivenessCheck; | ||||
| 	} | ||||
|  | ||||
| 	return <Button | ||||
| 		{...props} | ||||
| 		theme={theme} | ||||
| 		mode={mode} | ||||
| 		onPress={props.onPress} | ||||
| 	>{props.children}</Button>; | ||||
| }; | ||||
|  | ||||
| export default connect((state: AppState) => { | ||||
| 	return { themeId: state.settings.theme }; | ||||
| })(TextButton); | ||||
							
								
								
									
										14
									
								
								packages/app-mobile/components/buttons/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								packages/app-mobile/components/buttons/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| import * as React from 'react'; | ||||
| import TextButton, { ButtonType, TextButtonProps } from './TextButton'; | ||||
|  | ||||
| type Props = Omit<TextButtonProps, 'type'>; | ||||
|  | ||||
| const makeTextButtonComponent = (type: ButtonType) => { | ||||
| 	return (props: Props) => { | ||||
| 		return <TextButton {...props} type={type} />; | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
| export const PrimaryButton = makeTextButtonComponent(ButtonType.Primary); | ||||
| export const SecondaryButton = makeTextButtonComponent(ButtonType.Secondary); | ||||
| export const LinkButton = makeTextButtonComponent(ButtonType.Link); | ||||
| @@ -1,6 +1,6 @@ | ||||
| import * as React from 'react'; | ||||
| import { Platform, Linking, View, Switch, ScrollView, Text, TouchableOpacity, Alert, PermissionsAndroid, Dimensions, AccessibilityInfo } from 'react-native'; | ||||
| import Setting, { AppType, SettingItem, SettingMetadataSection } from '@joplin/lib/models/Setting'; | ||||
| import Setting, { AppType, SettingMetadataSection } from '@joplin/lib/models/Setting'; | ||||
| import NavService from '@joplin/lib/services/NavService'; | ||||
| import SearchEngine from '@joplin/lib/services/search/SearchEngine'; | ||||
| import checkPermissions from '../../../utils/checkPermissions'; | ||||
| @@ -26,7 +26,7 @@ import ExportProfileButton, { exportProfileButtonTitle } from './NoteExportSecti | ||||
| import SettingComponent from './SettingComponent'; | ||||
| import ExportDebugReportButton, { exportDebugReportTitle } from './NoteExportSection/ExportDebugReportButton'; | ||||
| import SectionSelector from './SectionSelector'; | ||||
| import { Button, TextInput } from 'react-native-paper'; | ||||
| import { TextInput, List } from 'react-native-paper'; | ||||
| import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import PluginStates, { getSearchText as getPluginStatesSearchText } from './plugins/PluginStates'; | ||||
| import PluginUploadButton, { canInstallPluginsFromFile, buttonLabel as pluginUploadButtonSearchText } from './plugins/PluginUploadButton'; | ||||
| @@ -389,7 +389,7 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi | ||||
| 		const addSettingComponent = ( | ||||
| 			component: ReactElement, | ||||
| 			relatedText: string|string[], | ||||
| 			settingMetadata?: SettingItem, | ||||
| 			settingMetadata?: { advanced?: boolean }, | ||||
| 		) => { | ||||
| 			const hiddenBySearch = this.state.searching && !matchesSearchQuery(relatedText); | ||||
| 			if (component && !hiddenBySearch) { | ||||
| @@ -503,8 +503,10 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi | ||||
| 							key='plugins-install-from-file' | ||||
| 							pluginSettings={settings[pluginStatesKey]} | ||||
| 							updatePluginStates={updatePluginStates} | ||||
| 							styles={this.styles()} | ||||
| 						/>, | ||||
| 						pluginUploadButtonSearchText(), | ||||
| 						{ advanced: true }, | ||||
| 					); | ||||
| 				} | ||||
| 			} else { | ||||
| @@ -663,19 +665,15 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi | ||||
| 		const renderAdvancedSettings = () => { | ||||
| 			if (!advancedSettingComps.length) return null; | ||||
|  | ||||
| 			const toggleAdvancedLabel = this.state.showAdvancedSettings ? _('Hide Advanced Settings') : _('Show Advanced Settings'); | ||||
| 			const toggleAdvancedLabel = _('Advanced settings'); | ||||
| 			return ( | ||||
| 				<> | ||||
| 					<Button | ||||
| 						style={{ marginBottom: 20 }} | ||||
| 						icon={this.state.showAdvancedSettings ? 'menu-down' : 'menu-right'} | ||||
| 						onPress={() => this.setState({ showAdvancedSettings: !this.state.showAdvancedSettings })} | ||||
| 					> | ||||
| 						<Text>{toggleAdvancedLabel}</Text> | ||||
| 					</Button> | ||||
|  | ||||
| 				<List.Accordion | ||||
| 					title={toggleAdvancedLabel} | ||||
| 					expanded={this.state.showAdvancedSettings} | ||||
| 					onPress={() => this.setState({ showAdvancedSettings: !this.state.showAdvancedSettings })} | ||||
| 				> | ||||
| 					{this.state.showAdvancedSettings ? advancedSettingComps : null} | ||||
| 				</> | ||||
| 				</List.Accordion> | ||||
| 			); | ||||
| 		}; | ||||
|  | ||||
|   | ||||
| @@ -3,7 +3,8 @@ import { themeStyle } from '../../../global-style'; | ||||
| import * as React from 'react'; | ||||
| import { useMemo } from 'react'; | ||||
| import { Linking, View, StyleSheet, ViewStyle, TextStyle } from 'react-native'; | ||||
| import { Button, Card, Divider, Icon, List, Text } from 'react-native-paper'; | ||||
| import { Card, Divider, Icon, List, Text } from 'react-native-paper'; | ||||
| import { LinkButton, PrimaryButton } from '../../../buttons'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| @@ -50,7 +51,6 @@ const useStyles = (themeId: number) => { | ||||
| 				marginBottom: 0, | ||||
| 			}, | ||||
| 			actionButton: { | ||||
| 				borderRadius: 10, | ||||
| 				marginLeft: theme.marginLeft * 2, | ||||
| 				marginRight: theme.marginRight * 2, | ||||
| 				marginBottom: theme.margin, | ||||
| @@ -58,18 +58,6 @@ const useStyles = (themeId: number) => { | ||||
| 		}); | ||||
|  | ||||
| 		const themeOverride = { | ||||
| 			secondaryButton: { | ||||
| 				colors: { | ||||
| 					primary: theme.color4, | ||||
| 					outline: theme.color4, | ||||
| 				}, | ||||
| 			}, | ||||
| 			primaryButton: { | ||||
| 				colors: { | ||||
| 					primary: theme.color4, | ||||
| 					onPrimary: theme.backgroundColor4, | ||||
| 				}, | ||||
| 			}, | ||||
| 			card: { | ||||
| 				colors: { | ||||
| 					outline: theme.codeBorderColor, | ||||
| @@ -127,8 +115,8 @@ const EnablePluginSupportPage: React.FC<Props> = props => { | ||||
| 			{renderCard('source-branch-check', _('Open Source'), _('Most plugins have source code available for review on the plugin website.'))} | ||||
| 			{renderCard('flag-remove', _('Report system'), _('We have a system for reporting and removing problematic plugins.'))} | ||||
| 			<View> | ||||
| 				<Button style={styles.actionButton} theme={themeOverride.secondaryButton} onPress={onLearnMorePress}>{_('Learn more')}</Button> | ||||
| 				<Button style={styles.actionButton} theme={themeOverride.primaryButton} mode='contained' onPress={props.onEnablePluginSupport}>{_('Enable plugin support')}</Button> | ||||
| 				<LinkButton style={styles.actionButton} onPress={onLearnMorePress}>{_('Learn more')}</LinkButton> | ||||
| 				<PrimaryButton style={styles.actionButton} onPress={props.onEnablePluginSupport}>{_('Enable plugin support')}</PrimaryButton> | ||||
| 			</View> | ||||
| 		</View> | ||||
| 	); | ||||
|   | ||||
| @@ -0,0 +1,52 @@ | ||||
| import * as React from 'react'; | ||||
| import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import { useMemo } from 'react'; | ||||
| import PluginBox from './PluginBox'; | ||||
| import useUpdateState from './utils/useUpdateState'; | ||||
| import { PluginCallback, PluginCallbacks } from './utils/usePluginCallbacks'; | ||||
| import usePluginItem from './utils/usePluginItem'; | ||||
| import { PluginStatusRecord } from '../types'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
|  | ||||
| 	pluginId: string; | ||||
| 	pluginSettings: PluginSettings; | ||||
| 	updatablePluginIds: PluginStatusRecord; | ||||
| 	updatingPluginIds: PluginStatusRecord; | ||||
| 	showInstalledChip: boolean; | ||||
|  | ||||
| 	callbacks: PluginCallbacks; | ||||
| 	onShowPluginInfo: PluginCallback; | ||||
| } | ||||
|  | ||||
| const InstalledPluginBox: React.FC<Props> = props => { | ||||
| 	const pluginId = props.pluginId; | ||||
| 	const updateState = useUpdateState({ | ||||
| 		pluginId, | ||||
| 		updatablePluginIds: props.updatablePluginIds, | ||||
| 		updatingPluginIds: props.updatingPluginIds, | ||||
| 		pluginSettings: props.pluginSettings, | ||||
| 	}); | ||||
| 	const pluginItem = usePluginItem(pluginId, props.pluginSettings, null); | ||||
|  | ||||
| 	const plugin = useMemo(() => PluginService.instance().pluginById(pluginId), [pluginId]); | ||||
| 	const isCompatible = useMemo(() => { | ||||
| 		return PluginService.instance().isCompatible(plugin.manifest); | ||||
| 	}, [plugin]); | ||||
|  | ||||
| 	return ( | ||||
| 		<PluginBox | ||||
| 			themeId={props.themeId} | ||||
| 			item={pluginItem} | ||||
| 			isCompatible={isCompatible} | ||||
| 			hasErrors={plugin.hasErrors} | ||||
| 			showInstalledChip={props.showInstalledChip} | ||||
| 			onShowPluginLog={props.callbacks.onShowPluginLog} | ||||
| 			onShowPluginInfo={props.onShowPluginInfo} | ||||
| 			updateState={updateState} | ||||
| 		/> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| export default InstalledPluginBox; | ||||
| @@ -0,0 +1,138 @@ | ||||
| import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import PluginService from '@joplin/lib/services/plugins/PluginService'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import * as React from 'react'; | ||||
| import { Alert, Linking, View, ViewStyle } from 'react-native'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { PluginCallback } from '../utils/usePluginCallbacks'; | ||||
| import StyledChip from './StyledChip'; | ||||
| import { themeStyle } from '../../../../global-style'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| 	item: PluginItem; | ||||
| 	hasErrors: boolean; | ||||
| 	isCompatible: boolean; | ||||
| 	canUpdate: boolean; | ||||
| 	showInstalledChip: boolean; | ||||
|  | ||||
| 	onShowPluginLog?: PluginCallback; | ||||
| } | ||||
|  | ||||
| const onRecommendedPress = () => { | ||||
| 	Alert.alert( | ||||
| 		'', | ||||
| 		_('The Joplin team has vetted this plugin and it meets our standards for security and performance.'), | ||||
| 		[ | ||||
| 			{ | ||||
| 				text: _('Learn more'), | ||||
| 				onPress: () => Linking.openURL('https://github.com/joplin/plugins/blob/master/readme/recommended.md'), | ||||
| 			}, | ||||
| 			{ | ||||
| 				text: _('OK'), | ||||
| 			}, | ||||
| 		], | ||||
| 		{ cancelable: true }, | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| const containerStyle: ViewStyle = { | ||||
| 	flexDirection: 'row', | ||||
| 	gap: 4, | ||||
|  | ||||
| 	// Smaller than default chip size | ||||
| 	transform: [{ scale: 0.84 }], | ||||
| 	transformOrigin: 'left', | ||||
| }; | ||||
|  | ||||
| const PluginChips: React.FC<Props> = props => { | ||||
| 	const item = props.item; | ||||
|  | ||||
| 	const theme = themeStyle(props.themeId); | ||||
|  | ||||
| 	const renderErrorsChip = () => { | ||||
| 		if (!props.hasErrors) return null; | ||||
|  | ||||
| 		return ( | ||||
| 			<StyledChip | ||||
| 				background={theme.backgroundColor2} | ||||
| 				foreground={theme.colorError2} | ||||
| 				icon='alert' | ||||
| 				mode='flat' | ||||
| 				onPress={() => props.onShowPluginLog({ item })} | ||||
| 			> | ||||
| 				{_('Error')} | ||||
| 			</StyledChip> | ||||
| 		); | ||||
| 	}; | ||||
|  | ||||
| 	const renderRecommendedChip = () => { | ||||
| 		if (!props.item.manifest._recommended || !props.isCompatible) { | ||||
| 			return null; | ||||
| 		} | ||||
| 		return <StyledChip | ||||
| 			background={theme.searchMarkerBackgroundColor} | ||||
| 			foreground={theme.searchMarkerColor} | ||||
| 			icon='crown' | ||||
| 			onPress={onRecommendedPress} | ||||
| 		>{_('Recommended')}</StyledChip>; | ||||
| 	}; | ||||
|  | ||||
| 	const renderBuiltInChip = () => { | ||||
| 		if (!props.item.builtIn) { | ||||
| 			return null; | ||||
| 		} | ||||
| 		return <StyledChip icon='code-tags-check'>{_('Built-in')}</StyledChip>; | ||||
| 	}; | ||||
|  | ||||
| 	const renderIncompatibleChip = () => { | ||||
| 		if (props.isCompatible) return null; | ||||
| 		return ( | ||||
| 			<StyledChip | ||||
| 				background={theme.backgroundColor3} | ||||
| 				foreground={theme.color3} | ||||
| 				icon='alert' | ||||
| 				onPress={() => { | ||||
| 					void shim.showMessageBox( | ||||
| 						PluginService.instance().describeIncompatibility(props.item.manifest), | ||||
| 						{ buttons: [_('OK')] }, | ||||
| 					); | ||||
| 				}} | ||||
| 			>{_('Incompatible')}</StyledChip> | ||||
| 		); | ||||
| 	}; | ||||
|  | ||||
| 	const renderUpdatableChip = () => { | ||||
| 		if (!props.isCompatible || !props.canUpdate) return null; | ||||
|  | ||||
| 		return ( | ||||
| 			<StyledChip>{_('Update available')}</StyledChip> | ||||
| 		); | ||||
| 	}; | ||||
|  | ||||
| 	const renderDisabledChip = () => { | ||||
| 		if (props.item.enabled || !props.item.installed) { | ||||
| 			return null; | ||||
| 		} | ||||
| 		return <StyledChip>{_('Disabled')}</StyledChip>; | ||||
| 	}; | ||||
|  | ||||
| 	const renderInstalledChip = () => { | ||||
| 		if (!props.showInstalledChip) { | ||||
| 			return null; | ||||
| 		} | ||||
| 		return <StyledChip>{_('Installed')}</StyledChip>; | ||||
| 	}; | ||||
|  | ||||
| 	return <View style={containerStyle}> | ||||
| 		{renderIncompatibleChip()} | ||||
| 		{renderInstalledChip()} | ||||
| 		{renderErrorsChip()} | ||||
| 		{renderRecommendedChip()} | ||||
| 		{renderBuiltInChip()} | ||||
| 		{renderUpdatableChip()} | ||||
| 		{renderDisabledChip()} | ||||
| 	</View>; | ||||
| }; | ||||
|  | ||||
| export default PluginChips; | ||||
| @@ -1,115 +0,0 @@ | ||||
| import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import * as React from 'react'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { useCallback, useMemo, useState } from 'react'; | ||||
| import { Button, IconButton, List, Portal, Text } from 'react-native-paper'; | ||||
| import getPluginIssueReportUrl from '@joplin/lib/services/plugins/utils/getPluginIssueReportUrl'; | ||||
| import { Linking, ScrollView, StyleSheet, View } from 'react-native'; | ||||
| import DismissibleDialog from '../../../../DismissibleDialog'; | ||||
| import openWebsiteForPlugin from '../utils/openWebsiteForPlugin'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| 	size: number; | ||||
| 	item: PluginItem; | ||||
| 	onModalDismiss?: ()=> void; | ||||
| } | ||||
|  | ||||
| const styles = StyleSheet.create({ | ||||
| 	aboutPluginContainer: { | ||||
| 		paddingLeft: 10, | ||||
| 		paddingRight: 10, | ||||
| 		paddingBottom: 10, | ||||
| 	}, | ||||
| 	descriptionText: { | ||||
| 		marginTop: 5, | ||||
| 		marginBottom: 5, | ||||
| 	}, | ||||
| 	fraudulentPluginButton: { | ||||
| 		opacity: 0.6, | ||||
| 	}, | ||||
| }); | ||||
|  | ||||
| const PluginInfoModal: React.FC<Props> = props => { | ||||
| 	const aboutPlugin = ( | ||||
| 		<View style={styles.aboutPluginContainer}> | ||||
| 			<Text variant='titleLarge'>{props.item.manifest.name}</Text> | ||||
| 			<Text variant='bodyLarge'>{props.item.manifest.author ? _('by %s', props.item.manifest.author) : ''}</Text> | ||||
| 			<Text style={styles.descriptionText}>{props.item.manifest.description ?? _('No description')}</Text> | ||||
| 		</View> | ||||
| 	); | ||||
|  | ||||
| 	const onAboutPress = useCallback(() => { | ||||
| 		void openWebsiteForPlugin({ item: props.item }); | ||||
| 	}, [props.item]); | ||||
|  | ||||
| 	const reportIssueUrl = useMemo(() => { | ||||
| 		return getPluginIssueReportUrl(props.item.manifest); | ||||
| 	}, [props.item]); | ||||
|  | ||||
| 	const onReportIssuePress = useCallback(() => { | ||||
| 		void Linking.openURL(reportIssueUrl); | ||||
| 	}, [reportIssueUrl]); | ||||
|  | ||||
| 	const reportIssueButton = ( | ||||
| 		<List.Item | ||||
| 			left={props => <List.Icon {...props} icon='bug'/>} | ||||
| 			title={_('Report an issue')} | ||||
| 			onPress={onReportIssuePress} | ||||
| 		/> | ||||
| 	); | ||||
|  | ||||
| 	const onReportFraudulentPress = useCallback(() => { | ||||
| 		void Linking.openURL('https://github.com/laurent22/joplin/security/advisories/new'); | ||||
| 	}, []); | ||||
|  | ||||
| 	return ( | ||||
| 		<Portal> | ||||
| 			<DismissibleDialog | ||||
| 				themeId={props.themeId} | ||||
| 				visible={true} | ||||
| 				onDismiss={props.onModalDismiss} | ||||
| 			> | ||||
| 				<ScrollView> | ||||
| 					{aboutPlugin} | ||||
| 					<List.Item | ||||
| 						left={props => <List.Icon {...props} icon='web'/>} | ||||
| 						title={_('About')} | ||||
| 						onPress={onAboutPress} | ||||
| 					/> | ||||
| 					{ reportIssueUrl ? reportIssueButton : null } | ||||
| 				</ScrollView> | ||||
| 				<Button | ||||
| 					icon='shield-bug' | ||||
| 					style={styles.fraudulentPluginButton} | ||||
| 					onPress={onReportFraudulentPress} | ||||
| 				>{_('Report fraudulent plugin')}</Button> | ||||
| 			</DismissibleDialog> | ||||
| 		</Portal> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| const PluginInfoButton: React.FC<Props> = props => { | ||||
| 	const [showInfoModal, setShowInfoModal] = useState(false); | ||||
| 	const onInfoButtonPress = useCallback(() => { | ||||
| 		setShowInfoModal(true); | ||||
| 	}, []); | ||||
|  | ||||
| 	const onModalDismiss = useCallback(() => { | ||||
| 		setShowInfoModal(false); | ||||
| 		props.onModalDismiss?.(); | ||||
| 	}, [props.onModalDismiss]); | ||||
|  | ||||
| 	return ( | ||||
| 		<> | ||||
| 			{showInfoModal ? <PluginInfoModal {...props} onModalDismiss={onModalDismiss} /> : null} | ||||
| 			<IconButton | ||||
| 				size={props.size} | ||||
| 				icon='information' | ||||
| 				onPress={onInfoButtonPress} | ||||
| 			/> | ||||
| 		</> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| export default PluginInfoButton; | ||||
| @@ -0,0 +1,31 @@ | ||||
| import * as React from 'react'; | ||||
| import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; | ||||
| import { Text } from 'react-native-paper'; | ||||
| import { StyleSheet } from 'react-native'; | ||||
|  | ||||
| interface Props { | ||||
| 	manifest: PluginManifest; | ||||
| } | ||||
|  | ||||
| const styles = StyleSheet.create({ | ||||
| 	versionText: { | ||||
| 		opacity: 0.8, | ||||
| 	}, | ||||
| 	title: { | ||||
| 		// Prevents the title text from being clipped on Android | ||||
| 		verticalAlign: 'middle', | ||||
| 		fontWeight: 'bold', | ||||
| 	}, | ||||
| }); | ||||
|  | ||||
| const PluginTitle: React.FC<Props> = props => { | ||||
| 	return <Text style={styles.title}> | ||||
| 		<Text variant='titleMedium'>{ | ||||
| 			props.manifest.name | ||||
| 		}</Text>  <Text variant='bodySmall' style={styles.versionText}>v{ | ||||
| 			props.manifest.version | ||||
| 		}</Text> | ||||
| 	</Text>; | ||||
| }; | ||||
|  | ||||
| export default PluginTitle; | ||||
| @@ -0,0 +1,39 @@ | ||||
| import * as React from 'react'; | ||||
| import { Chip, ChipProps } from 'react-native-paper'; | ||||
| import { useMemo } from 'react'; | ||||
|  | ||||
| type Props = ({ | ||||
| 	foreground: string; | ||||
| 	background: string; | ||||
| }|{ | ||||
| 	foreground?: undefined; | ||||
| 	background?: undefined; | ||||
| }) & ChipProps; | ||||
|  | ||||
| const RecommendedChip: React.FC<Props> = props => { | ||||
| 	const themeOverride = useMemo(() => { | ||||
| 		if (!props.foreground) return {}; | ||||
| 		return { | ||||
| 			colors: { | ||||
| 				secondaryContainer: props.background, | ||||
| 				onSecondaryContainer: props.foreground, | ||||
| 				primary: props.foreground, | ||||
| 			}, | ||||
| 		}; | ||||
| 	}, [props.foreground, props.background]); | ||||
|  | ||||
| 	const accessibilityProps: Partial<Props> = {}; | ||||
| 	if (!props.onPress) { | ||||
| 		// Note: May have no effect until a future version of RN Paper. | ||||
| 		// See https://github.com/callstack/react-native-paper/pull/4327 | ||||
| 		accessibilityProps.accessibilityRole = 'text'; | ||||
| 	} | ||||
|  | ||||
| 	return <Chip | ||||
| 		theme={themeOverride} | ||||
| 		{...accessibilityProps} | ||||
| 		{...props} | ||||
| 	/>; | ||||
| }; | ||||
|  | ||||
| export default RecommendedChip; | ||||
| @@ -1,12 +1,16 @@ | ||||
| import * as React from 'react'; | ||||
| import { Icon, Card, Chip, Text } from 'react-native-paper'; | ||||
| import { Card, Text, TouchableRipple } from 'react-native-paper'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { Alert, Linking, StyleSheet, View } from 'react-native'; | ||||
| import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import PluginService from '@joplin/lib/services/plugins/PluginService'; | ||||
| import ActionButton, { PluginCallback } from './ActionButton'; | ||||
| import PluginInfoButton from './PluginInfoButton'; | ||||
| import ActionButton from '../buttons/ActionButton'; | ||||
| import { ButtonType } from '../../../../buttons/TextButton'; | ||||
| import PluginChips from './PluginChips'; | ||||
| import { UpdateState } from '../utils/useUpdateState'; | ||||
| import { PluginCallback } from '../utils/usePluginCallbacks'; | ||||
| import { useCallback, useMemo } from 'react'; | ||||
| import { StyleSheet } from 'react-native'; | ||||
| import InstallButton from '../buttons/InstallButton'; | ||||
| import PluginTitle from './PluginTitle'; | ||||
|  | ||||
| export enum InstallState { | ||||
| 	NotInstalled, | ||||
| @@ -14,197 +18,98 @@ export enum InstallState { | ||||
| 	Installed, | ||||
| } | ||||
|  | ||||
| export enum UpdateState { | ||||
| 	Idle = 1, | ||||
| 	CanUpdate = 2, | ||||
| 	Updating = 3, | ||||
| 	HasBeenUpdated = 4, | ||||
| } | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| 	item: PluginItem; | ||||
| 	isCompatible: boolean; | ||||
|  | ||||
| 	// In some cases, showing an "installed" chip is redundant (e.g. in the "installed plugins" | ||||
| 	// tab). In other places (e.g. search), an "installed" chip is important. | ||||
| 	showInstalledChip: boolean; | ||||
|  | ||||
| 	hasErrors?: boolean; | ||||
| 	installState?: InstallState; | ||||
| 	updateState?: UpdateState; | ||||
|  | ||||
| 	onAboutPress?: PluginCallback; | ||||
| 	onInstall?: PluginCallback; | ||||
| 	onUpdate?: PluginCallback; | ||||
| 	onDelete?: PluginCallback; | ||||
| 	onToggle?: PluginCallback; | ||||
| 	onShowPluginLog?: PluginCallback; | ||||
| 	onShowPluginInfo?: PluginCallback; | ||||
| } | ||||
|  | ||||
| const onRecommendedPress = () => { | ||||
| 	Alert.alert( | ||||
| 		'', | ||||
| 		_('The Joplin team has vetted this plugin and it meets our standards for security and performance.'), | ||||
| 		[ | ||||
| 			{ | ||||
| 				text: _('Learn more'), | ||||
| 				onPress: () => Linking.openURL('https://github.com/joplin/plugins/blob/master/readme/recommended.md'), | ||||
| const useStyles = (compatible: boolean) => { | ||||
| 	return useMemo(() => { | ||||
| 		// For the TouchableRipple to work on Android, the card needs a transparent background. | ||||
| 		const baseCard = { backgroundColor: 'transparent' }; | ||||
| 		return StyleSheet.create({ | ||||
| 			cardContainer: { | ||||
| 				margin: 0, | ||||
| 				marginTop: 8, | ||||
| 				padding: 0, | ||||
| 				borderRadius: 14, | ||||
| 			}, | ||||
| 			{ | ||||
| 				text: _('OK'), | ||||
| 			card: !compatible ? { | ||||
| 				...baseCard, | ||||
| 				opacity: 0.7, | ||||
| 			} : baseCard, | ||||
| 			content: { | ||||
| 				gap: 5, | ||||
| 			}, | ||||
| 		], | ||||
| 		{ cancelable: true }, | ||||
| 	); | ||||
| 		}); | ||||
| 	}, [compatible]); | ||||
| }; | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| const PluginIcon = (props: any) => <Icon {...props} source='puzzle'/>; | ||||
|  | ||||
| const styles = StyleSheet.create({ | ||||
| 	versionText: { | ||||
| 		opacity: 0.8, | ||||
| 	}, | ||||
| 	title: { | ||||
| 		// Prevents the title text from being clipped on Android | ||||
| 		verticalAlign: 'middle', | ||||
| 	}, | ||||
| }); | ||||
|  | ||||
| const PluginBox: React.FC<Props> = props => { | ||||
| 	const manifest = props.item.manifest; | ||||
| 	const item = props.item; | ||||
|  | ||||
| 	const installButtonTitle = () => { | ||||
| 		if (props.installState === InstallState.Installing) return _('Installing...'); | ||||
| 		if (props.installState === InstallState.NotInstalled) return _('Install'); | ||||
| 		if (props.installState === InstallState.Installed) return _('Installed'); | ||||
| 		return `Invalid install state: ${props.installState}`; | ||||
| 	}; | ||||
| 	const installButton = <InstallButton | ||||
| 		item={item} | ||||
| 		onInstall={props.onInstall} | ||||
| 		installState={props.installState} | ||||
| 		isCompatible={props.isCompatible} | ||||
| 	/>; | ||||
|  | ||||
| 	const installButton = ( | ||||
| 		<ActionButton | ||||
| 			item={item} | ||||
| 			onPress={props.onInstall} | ||||
| 			disabled={props.installState !== InstallState.NotInstalled || !props.isCompatible} | ||||
| 			loading={props.installState === InstallState.Installing} | ||||
| 			title={installButtonTitle()} | ||||
| 		/> | ||||
| 	); | ||||
| 	const aboutButton = <ActionButton type={ButtonType.Link} item={item} onPress={props.onAboutPress} title={_('About')}/>; | ||||
|  | ||||
| 	const getUpdateButtonTitle = () => { | ||||
| 		if (props.updateState === UpdateState.Updating) return _('Updating...'); | ||||
| 		if (props.updateState === UpdateState.HasBeenUpdated) return _('Updated'); | ||||
| 		return _('Update'); | ||||
| 	}; | ||||
| 	const onPress = useCallback(() => { | ||||
| 		props.onShowPluginInfo?.({ item: props.item }); | ||||
| 	}, [props.onShowPluginInfo, props.item]); | ||||
|  | ||||
| 	const updateButton = ( | ||||
| 		<ActionButton | ||||
| 			item={item} | ||||
| 			onPress={props.onUpdate} | ||||
| 			disabled={props.updateState !== UpdateState.CanUpdate || !props.isCompatible} | ||||
| 			loading={props.updateState === UpdateState.Updating} | ||||
| 			title={getUpdateButtonTitle()} | ||||
| 		/> | ||||
| 	); | ||||
| 	const styles = useStyles(props.isCompatible); | ||||
|  | ||||
| 	const deleteButton = ( | ||||
| 		<ActionButton | ||||
| 			item={item} | ||||
| 			onPress={props.onDelete} | ||||
| 			disabled={props.item.deleted} | ||||
| 			title={props.item.deleted ? _('Deleted') : _('Delete')} | ||||
| 		/> | ||||
| 	); | ||||
| 	const disableButton = <ActionButton item={item} onPress={props.onToggle} title={_('Disable')}/>; | ||||
| 	const enableButton = <ActionButton item={item} onPress={props.onToggle} title={_('Enable')}/>; | ||||
| 	const aboutButton = <ActionButton item={item} onPress={props.onAboutPress} icon='web' title={_('About')}/>; | ||||
|  | ||||
| 	const renderErrorsChip = () => { | ||||
| 		if (!props.hasErrors) return null; | ||||
|  | ||||
| 		return ( | ||||
| 			<Chip | ||||
| 				icon='alert' | ||||
| 				mode='outlined' | ||||
| 				onPress={() => props.onShowPluginLog({ item })} | ||||
| 			> | ||||
| 				{_('Error')} | ||||
| 			</Chip> | ||||
| 		); | ||||
| 	}; | ||||
|  | ||||
| 	const renderRecommendedChip = () => { | ||||
| 		if (!props.item.manifest._recommended || !props.isCompatible) { | ||||
| 			return null; | ||||
| 		} | ||||
| 		return <Chip | ||||
| 			icon='crown' | ||||
| 			mode='outlined' | ||||
| 			onPress={onRecommendedPress} | ||||
| 		> | ||||
| 			{_('Recommended')} | ||||
| 		</Chip>; | ||||
| 	}; | ||||
|  | ||||
| 	const renderBuiltInChip = () => { | ||||
| 		if (!props.item.builtIn) { | ||||
| 			return null; | ||||
| 		} | ||||
| 		return <Chip icon='code-tags-check' mode='outlined'>{_('Built-in')}</Chip>; | ||||
| 	}; | ||||
|  | ||||
| 	const renderIncompatibleChip = () => { | ||||
| 		if (props.isCompatible) return null; | ||||
| 		return ( | ||||
| 			<Chip | ||||
| 				icon='alert' | ||||
| 				mode='outlined' | ||||
| 				onPress={() => { | ||||
| 					void shim.showMessageBox( | ||||
| 						PluginService.instance().describeIncompatibility(props.item.manifest), | ||||
| 						{ buttons: [_('OK')] }, | ||||
| 					); | ||||
| 				}} | ||||
| 			>{_('Incompatible')}</Chip> | ||||
| 		); | ||||
| 	}; | ||||
|  | ||||
| 	const renderRightEdgeButton = (buttonProps: { size: number }) => { | ||||
| 		// If .onAboutPress is given (e.g. when searching), there's another way to get information | ||||
| 		// about the plugin. In this case, we don't show the right-side information link. | ||||
| 		if (props.onAboutPress) return null; | ||||
| 		return <PluginInfoButton {...buttonProps} themeId={props.themeId} item={props.item}/>; | ||||
| 	}; | ||||
|  | ||||
| 	const updateStateIsIdle = props.updateState !== UpdateState.Idle; | ||||
|  | ||||
| 	const titleComponent = <> | ||||
| 		<Text variant='titleMedium'>{manifest.name}</Text> <Text variant='bodySmall' style={styles.versionText}>v{manifest.version}</Text> | ||||
| 	</>; | ||||
| 	return ( | ||||
| 		<Card style={{ margin: 8, opacity: props.isCompatible ? undefined : 0.75 }} testID='plugin-card'> | ||||
| 			<Card.Title | ||||
| 				title={titleComponent} | ||||
| 				titleStyle={styles.title} | ||||
| 				subtitle={manifest.description} | ||||
| 				left={PluginIcon} | ||||
| 				right={renderRightEdgeButton} | ||||
| 			/> | ||||
| 			<Card.Content> | ||||
| 				<View style={{ flexDirection: 'row' }}> | ||||
| 					{renderIncompatibleChip()} | ||||
| 					{renderErrorsChip()} | ||||
| 					{renderRecommendedChip()} | ||||
| 					{renderBuiltInChip()} | ||||
| 				</View> | ||||
| 			</Card.Content> | ||||
| 			<Card.Actions> | ||||
| 				{props.onAboutPress ? aboutButton : null} | ||||
| 				{props.onInstall ? installButton : null} | ||||
| 				{props.onDelete && !props.item.builtIn ? deleteButton : null} | ||||
| 				{props.onUpdate && updateStateIsIdle ? updateButton : null} | ||||
| 				{props.onToggle && props.item.enabled ? disableButton : null} | ||||
| 				{props.onToggle && !props.item.enabled ? enableButton : null} | ||||
| 			</Card.Actions> | ||||
| 		</Card> | ||||
| 		<TouchableRipple | ||||
| 			accessibilityRole='button' | ||||
| 			accessible={true} | ||||
| 			onPress={props.onShowPluginInfo ? onPress : null} | ||||
| 			style={styles.cardContainer} | ||||
| 		> | ||||
| 			<Card | ||||
| 				mode='outlined' | ||||
| 				style={styles.card} | ||||
| 				testID='plugin-card' | ||||
| 			> | ||||
| 				<Card.Content style={styles.content}> | ||||
| 					<PluginTitle manifest={item.manifest} /> | ||||
| 					<Text numberOfLines={2}>{manifest.description}</Text> | ||||
| 					<PluginChips | ||||
| 						themeId={props.themeId} | ||||
| 						item={props.item} | ||||
| 						showInstalledChip={props.showInstalledChip} | ||||
| 						hasErrors={props.hasErrors} | ||||
| 						canUpdate={props.updateState === UpdateState.CanUpdate} | ||||
| 						onShowPluginLog={props.onShowPluginLog} | ||||
| 						isCompatible={props.isCompatible} | ||||
| 					/> | ||||
| 				</Card.Content> | ||||
| 				<Card.Actions> | ||||
| 					{props.onAboutPress ? aboutButton : null} | ||||
| 					{props.onInstall ? installButton : null} | ||||
| 				</Card.Actions> | ||||
| 			</Card> | ||||
| 		</TouchableRipple> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,265 @@ | ||||
| import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import * as React from 'react'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { useCallback, useMemo } from 'react'; | ||||
| import { Card, Divider, List, Portal, Switch, Text } from 'react-native-paper'; | ||||
| import getPluginIssueReportUrl from '@joplin/lib/services/plugins/utils/getPluginIssueReportUrl'; | ||||
| import { Linking, ScrollView, StyleSheet, View, ViewStyle } from 'react-native'; | ||||
| import DismissibleDialog, { DialogSize } from '../../../DismissibleDialog'; | ||||
| import openWebsiteForPlugin from './utils/openWebsiteForPlugin'; | ||||
| import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import PluginTitle from './PluginBox/PluginTitle'; | ||||
| import ActionButton from './buttons/ActionButton'; | ||||
| import TextButton, { ButtonType } from '../../../buttons/TextButton'; | ||||
| import useUpdateState, { UpdateState } from './utils/useUpdateState'; | ||||
| import { PluginCallback, PluginCallbacks } from './utils/usePluginCallbacks'; | ||||
| import usePluginItem from './utils/usePluginItem'; | ||||
| import InstallButton from './buttons/InstallButton'; | ||||
| import { InstallState } from './PluginBox'; | ||||
| import PluginChips from './PluginBox/PluginChips'; | ||||
| import { PluginStatusRecord } from '../types'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
|  | ||||
| 	visible: boolean; | ||||
| 	item: PluginItem|null; | ||||
|  | ||||
| 	updatablePluginIds: PluginStatusRecord; | ||||
| 	updatingPluginIds: PluginStatusRecord; | ||||
| 	installingPluginIds: PluginStatusRecord; | ||||
|  | ||||
| 	pluginCallbacks: PluginCallbacks; | ||||
| 	pluginSettings: PluginSettings; | ||||
| 	onModalDismiss: ()=> void; | ||||
| } | ||||
|  | ||||
| const styles = (() => { | ||||
| 	const baseButtonContainer: ViewStyle = { | ||||
| 		display: 'flex', | ||||
| 		flexDirection: 'column', | ||||
| 		gap: 20, | ||||
| 		marginLeft: 10, | ||||
| 		marginRight: 10, | ||||
| 	}; | ||||
| 	return StyleSheet.create({ | ||||
| 		descriptionText: { | ||||
| 			marginTop: 5, | ||||
| 			marginBottom: 5, | ||||
| 		}, | ||||
| 		buttonContainer: { | ||||
| 			...baseButtonContainer, | ||||
| 			marginTop: 26, | ||||
| 			marginBottom: 26, | ||||
| 		}, | ||||
| 		accordionContent: { | ||||
| 			...baseButtonContainer, | ||||
| 			marginTop: 12, | ||||
| 		}, | ||||
| 		fraudulentPluginButton: { | ||||
| 			opacity: 0.6, | ||||
| 		}, | ||||
| 		enabledSwitchContainer: { | ||||
| 			display: 'flex', | ||||
| 			flexDirection: 'row', | ||||
| 			justifyContent: 'space-between', | ||||
| 			alignItems: 'center', | ||||
| 			padding: 10, | ||||
| 			marginTop: 12, | ||||
| 			marginBottom: 14, | ||||
| 		}, | ||||
| 		pluginDescriptionContainer: { | ||||
| 			marginTop: 8, | ||||
| 			gap: 8, | ||||
| 		}, | ||||
| 	}); | ||||
| })(); | ||||
|  | ||||
| interface EnabledSwitchProps { | ||||
| 	item: PluginItem; | ||||
| 	onToggle: PluginCallback; | ||||
| } | ||||
|  | ||||
| const EnabledSwitch: React.FC<EnabledSwitchProps> = props => { | ||||
| 	const onChange = useCallback((value: boolean) => { | ||||
| 		if (value !== props.item.enabled) { | ||||
| 			props.onToggle({ item: props.item }); | ||||
| 		} | ||||
| 	}, [props.item, props.onToggle]); | ||||
|  | ||||
| 	if (!props.item?.installed || props.item.deleted) { | ||||
| 		return null; | ||||
| 	} | ||||
|  | ||||
| 	return <View style={styles.enabledSwitchContainer}> | ||||
| 		<Text nativeID='enabledLabel'>{_('Enabled')}</Text> | ||||
| 		<Switch accessibilityLabelledBy='enabledLabel' value={props.item.enabled} onValueChange={onChange} /> | ||||
| 	</View>; | ||||
| }; | ||||
|  | ||||
| const PluginInfoModalContent: React.FC<Props> = props => { | ||||
| 	const initialItem = props.item; | ||||
| 	const pluginId = initialItem.manifest.id; | ||||
| 	const item = usePluginItem(pluginId, props.pluginSettings, initialItem); | ||||
|  | ||||
| 	const manifest = item.manifest; | ||||
| 	const isCompatible = useMemo(() => { | ||||
| 		return PluginService.instance().isCompatible(manifest); | ||||
| 	}, [manifest]); | ||||
|  | ||||
| 	const plugin = useMemo(() => { | ||||
| 		const service = PluginService.instance(); | ||||
| 		if (!service.pluginIds.includes(pluginId)) { | ||||
| 			return null; | ||||
| 		} | ||||
| 		return service.pluginById(pluginId); | ||||
| 	}, [pluginId]); | ||||
|  | ||||
| 	const updateState = useUpdateState({ | ||||
| 		pluginId: plugin?.id, | ||||
| 		pluginSettings: props.pluginSettings, | ||||
| 		updatablePluginIds: props.updatablePluginIds, | ||||
| 		updatingPluginIds: props.updatingPluginIds, | ||||
| 	}); | ||||
|  | ||||
| 	const aboutPlugin = ( | ||||
| 		<Card mode='outlined' style={{ margin: 8 }} testID='plugin-card'> | ||||
| 			<Card.Content> | ||||
| 				<PluginTitle manifest={manifest}/> | ||||
| 				<Text variant='bodyMedium'>{_('by %s', manifest.author)}</Text> | ||||
| 				<View style={styles.pluginDescriptionContainer}> | ||||
| 					<PluginChips | ||||
| 						themeId={props.themeId} | ||||
| 						item={item} | ||||
| 						showInstalledChip={false} | ||||
| 						hasErrors={plugin.hasErrors} | ||||
| 						canUpdate={false} | ||||
| 						onShowPluginLog={props.pluginCallbacks.onShowPluginLog} | ||||
| 						isCompatible={isCompatible} | ||||
| 					/> | ||||
| 					<Text>{manifest.description}</Text> | ||||
| 				</View> | ||||
| 			</Card.Content> | ||||
| 		</Card> | ||||
| 	); | ||||
|  | ||||
| 	const onAboutPress = useCallback(() => { | ||||
| 		void openWebsiteForPlugin({ item }); | ||||
| 	}, [item]); | ||||
|  | ||||
| 	const reportIssueUrl = useMemo(() => { | ||||
| 		return getPluginIssueReportUrl(manifest); | ||||
| 	}, [manifest]); | ||||
|  | ||||
| 	const onReportIssuePress = useCallback(() => { | ||||
| 		void Linking.openURL(reportIssueUrl); | ||||
| 	}, [reportIssueUrl]); | ||||
|  | ||||
| 	const reportIssueButton = ( | ||||
| 		<TextButton | ||||
| 			type={ButtonType.Secondary} | ||||
| 			onPress={onReportIssuePress} | ||||
| 		>{_('Report an issue')}</TextButton> | ||||
| 	); | ||||
|  | ||||
| 	const onReportFraudulentPress = useCallback(() => { | ||||
| 		void Linking.openURL('https://github.com/laurent22/joplin/security/advisories/new'); | ||||
| 	}, []); | ||||
|  | ||||
| 	const getUpdateButtonTitle = () => { | ||||
| 		if (updateState === UpdateState.Updating) return _('Updating...'); | ||||
| 		if (updateState === UpdateState.HasBeenUpdated) return _('Updated'); | ||||
| 		return _('Update'); | ||||
| 	}; | ||||
|  | ||||
| 	const updateButton = ( | ||||
| 		<ActionButton | ||||
| 			item={item} | ||||
| 			type={ButtonType.Secondary} | ||||
| 			onPress={props.pluginCallbacks.onUpdate} | ||||
| 			disabled={updateState !== UpdateState.CanUpdate || !isCompatible} | ||||
| 			loading={updateState === UpdateState.Updating} | ||||
| 			title={getUpdateButtonTitle()} | ||||
| 		/> | ||||
| 	); | ||||
|  | ||||
| 	const installState = (() => { | ||||
| 		if (item.installed) return InstallState.Installed; | ||||
| 		if (props.installingPluginIds[pluginId]) return InstallState.Installing; | ||||
| 		return InstallState.NotInstalled; | ||||
| 	})(); | ||||
|  | ||||
| 	const installButton = ( | ||||
| 		<InstallButton | ||||
| 			item={item} | ||||
| 			onInstall={props.pluginCallbacks.onInstall} | ||||
| 			installState={installState} | ||||
| 			isCompatible={isCompatible} | ||||
| 		/> | ||||
| 	); | ||||
|  | ||||
| 	const deleteButton = ( | ||||
| 		<ActionButton | ||||
| 			item={item} | ||||
| 			type={ButtonType.Delete} | ||||
| 			onPress={props.pluginCallbacks.onDelete} | ||||
| 			disabled={item.builtIn || (item?.deleted ?? true)} | ||||
| 			title={item?.deleted ? _('Deleted') : _('Delete')} | ||||
| 		/> | ||||
| 	); | ||||
|  | ||||
| 	const deleteButtonContainer = <> | ||||
| 		<View style={styles.buttonContainer}> | ||||
| 			{deleteButton} | ||||
| 		</View> | ||||
| 		<Divider /> | ||||
| 	</>; | ||||
|  | ||||
| 	const reportIssuesContainer = ( | ||||
| 		<List.Accordion title={_('Report any issues concerning the plugin.')} titleNumberOfLines={2}> | ||||
| 			<View style={styles.accordionContent}> | ||||
| 				<TextButton | ||||
| 					type={ButtonType.Secondary} | ||||
| 					onPress={onReportFraudulentPress} | ||||
| 				>{_('Report fraudulent plugin')}</TextButton> | ||||
| 				{reportIssueButton} | ||||
| 			</View> | ||||
| 		</List.Accordion> | ||||
| 	); | ||||
|  | ||||
| 	return <> | ||||
| 		<ScrollView> | ||||
| 			{aboutPlugin} | ||||
| 			<EnabledSwitch item={item} onToggle={props.pluginCallbacks.onToggle}/> | ||||
| 			<Divider /> | ||||
| 			<View style={styles.buttonContainer}> | ||||
| 				{!item.installed ? installButton : null} | ||||
| 				<TextButton | ||||
| 					type={item.installed ? ButtonType.Primary : ButtonType.Secondary} | ||||
| 					onPress={onAboutPress} | ||||
| 				>{_('About')}</TextButton> | ||||
| 				{updateState !== UpdateState.Idle ? updateButton : null} | ||||
| 			</View> | ||||
| 			<Divider /> | ||||
| 			{ item.installed ? deleteButtonContainer : null } | ||||
| 			{reportIssuesContainer} | ||||
| 		</ScrollView> | ||||
| 	</>; | ||||
| }; | ||||
|  | ||||
| const PluginInfoModal: React.FC<Props> = props => { | ||||
| 	return ( | ||||
| 		<Portal> | ||||
| 			<DismissibleDialog | ||||
| 				themeId={props.themeId} | ||||
| 				visible={props.visible} | ||||
| 				size={DialogSize.Small} | ||||
| 				onDismiss={props.onModalDismiss} | ||||
| 			> | ||||
| 				{ props.item ? <PluginInfoModalContent {...props}/> : null } | ||||
| 			</DismissibleDialog> | ||||
| 		</Portal> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| export default PluginInfoModal; | ||||
| @@ -1,65 +1,25 @@ | ||||
| import * as React from 'react'; | ||||
| import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi'; | ||||
| import { createTempDir, mockMobilePlatform, setupDatabaseAndSynchronizer, supportDir, switchClient } from '@joplin/lib/testing/test-utils'; | ||||
| import { createTempDir, mockMobilePlatform, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils'; | ||||
| 
 | ||||
| import { act, render, screen } from '@testing-library/react-native'; | ||||
| import { act, fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native'; | ||||
| import '@testing-library/react-native/extend-expect'; | ||||
| 
 | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import PluginService, { PluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import { useCallback, useState } from 'react'; | ||||
| import pluginServiceSetup from './testUtils/pluginServiceSetup'; | ||||
| import PluginStates from './PluginStates'; | ||||
| import configScreenStyles from '../configScreenStyles'; | ||||
| import { remove, writeFile } from 'fs-extra'; | ||||
| import { writeFile } from 'fs-extra'; | ||||
| import { join } from 'path'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import { resetRepoApi } from './utils/useRepoApi'; | ||||
| import { Store } from 'redux'; | ||||
| import { AppState } from '../../../../utils/types'; | ||||
| import createMockReduxStore from '../../../../utils/testing/createMockReduxStore'; | ||||
| import WrappedPluginStates from './testUtils/WrappedPluginStates'; | ||||
| import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| 
 | ||||
| interface WrapperProps { | ||||
| 	initialPluginSettings: PluginSettings; | ||||
| } | ||||
| 
 | ||||
| let reduxStore: Store<AppState> = null; | ||||
| 
 | ||||
| const shouldShowBasedOnSettingSearchQuery = ()=>true; | ||||
| const PluginStatesWrapper = (props: WrapperProps) => { | ||||
| 	const styles = configScreenStyles(Setting.THEME_LIGHT); | ||||
| 
 | ||||
| 	const [pluginSettings, setPluginSettings] = useState(() => { | ||||
| 		return props.initialPluginSettings ?? {}; | ||||
| 	}); | ||||
| 
 | ||||
| 	const updatePluginStates = useCallback((newStates: PluginSettings) => { | ||||
| 		setPluginSettings(newStates); | ||||
| 	}, []); | ||||
| 
 | ||||
| 	return ( | ||||
| 		<PluginStates | ||||
| 			styles={styles} | ||||
| 			themeId={Setting.THEME_LIGHT} | ||||
| 			updatePluginStates={updatePluginStates} | ||||
| 			pluginSettings={pluginSettings} | ||||
| 			shouldShowBasedOnSearchQuery={shouldShowBasedOnSettingSearchQuery} | ||||
| 		/> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| let repoTempDir: string|null = null; | ||||
| const mockRepositoryApiConstructor = async () => { | ||||
| 	if (repoTempDir) { | ||||
| 		await remove(repoTempDir); | ||||
| 	} | ||||
| 	repoTempDir = await createTempDir(); | ||||
| 
 | ||||
| 	RepositoryApi.ofDefaultJoplinRepo = jest.fn((_tempDirPath: string, appType, installMode) => { | ||||
| 		return new RepositoryApi(`${supportDir}/pluginRepo`, repoTempDir, appType, installMode); | ||||
| 	}); | ||||
| }; | ||||
| 
 | ||||
| const loadMockPlugin = async (id: string, name: string, version: string, pluginSettings: PluginSettings) => { | ||||
| 	const service = PluginService.instance(); | ||||
| 	const pluginSource = ` | ||||
| @@ -87,7 +47,12 @@ const loadMockPlugin = async (id: string, name: string, version: string, pluginS | ||||
| 	}); | ||||
| }; | ||||
| 
 | ||||
| describe('PluginStates', () => { | ||||
| const showInstalledTab = async () => { | ||||
| 	const installedTab = await screen.findByText('Installed plugins'); | ||||
| 	await userEvent.press(installedTab); | ||||
| }; | ||||
| 
 | ||||
| describe('PluginStates.installed', () => { | ||||
| 	beforeEach(async () => { | ||||
| 		await setupDatabaseAndSynchronizer(0); | ||||
| 		await switchClient(0); | ||||
| @@ -128,23 +93,27 @@ describe('PluginStates', () => { | ||||
| 		await loadMockPlugin(backlinksPluginId, 'Backlinks to note', '0.0.1', defaultPluginSettings); | ||||
| 		expect(PluginService.instance().plugins[backlinksPluginId]).toBeTruthy(); | ||||
| 
 | ||||
| 		render( | ||||
| 			<PluginStatesWrapper | ||||
| 		const wrapper = render( | ||||
| 			<WrappedPluginStates | ||||
| 				initialPluginSettings={defaultPluginSettings} | ||||
| 				store={reduxStore} | ||||
| 			/>, | ||||
| 		); | ||||
| 		await showInstalledTab(); | ||||
| 		expect(await screen.findByText(/^ABC Sheet Music/)).toBeVisible(); | ||||
| 		expect(await screen.findByText(/^Backlinks to note/)).toBeVisible(); | ||||
| 
 | ||||
| 		expect(await screen.findByRole('button', { name: 'Update ABC Sheet Music', disabled: false })).toBeVisible(); | ||||
| 		const updateMarkers = await screen.findAllByText('Update available'); | ||||
| 
 | ||||
| 		// Backlinks to note should not be updatable on iOS (it's not _recommended).
 | ||||
| 		const backlinksToNoteQuery = { name: 'Update Backlinks to note', disabled: false }; | ||||
| 		// ABC Sheet Music should always be updatable
 | ||||
| 		if (platform === 'android') { | ||||
| 			expect(await screen.findByRole('button', backlinksToNoteQuery)).toBeVisible(); | ||||
| 			expect(updateMarkers).toHaveLength(2); | ||||
| 		} else { | ||||
| 			expect(await screen.queryByRole('button', backlinksToNoteQuery)).toBeNull(); | ||||
| 			expect(updateMarkers).toHaveLength(1); | ||||
| 		} | ||||
| 
 | ||||
| 		wrapper.unmount(); | ||||
| 	}); | ||||
| 
 | ||||
| 	it('should show the current plugin version on updatable plugins', async () => { | ||||
| @@ -155,24 +124,32 @@ describe('PluginStates', () => { | ||||
| 		await loadMockPlugin(abcPluginId, 'ABC Sheet Music', outdatedVersion, defaultPluginSettings); | ||||
| 		expect(PluginService.instance().plugins[abcPluginId]).toBeTruthy(); | ||||
| 
 | ||||
| 		render( | ||||
| 			<PluginStatesWrapper | ||||
| 		const wrapper = render( | ||||
| 			<WrappedPluginStates | ||||
| 				initialPluginSettings={defaultPluginSettings} | ||||
| 				store={reduxStore} | ||||
| 			/>, | ||||
| 		); | ||||
| 		expect(await screen.findByText(/^ABC Sheet Music/)).toBeVisible(); | ||||
| 		expect(await screen.findByRole('button', { name: 'Update ABC Sheet Music', disabled: false })).toBeVisible(); | ||||
| 		await showInstalledTab(); | ||||
| 
 | ||||
| 		const abcSheetMusicCard = await screen.findByText(/^ABC Sheet Music/); | ||||
| 		expect(abcSheetMusicCard).toBeVisible(); | ||||
| 		expect(await screen.findByText('Update available')).toBeVisible(); | ||||
| 		expect(await screen.findByText(`v${outdatedVersion}`)).toBeVisible(); | ||||
| 
 | ||||
| 		wrapper.unmount(); | ||||
| 	}); | ||||
| 
 | ||||
| 	it('should update the list of installed plugins when a plugin is installed and uninstalled', async () => { | ||||
| 		const pluginSettings: PluginSettings = { }; | ||||
| 
 | ||||
| 		render( | ||||
| 			<PluginStatesWrapper | ||||
| 		const wrapper = render( | ||||
| 			<WrappedPluginStates | ||||
| 				initialPluginSettings={pluginSettings} | ||||
| 				store={reduxStore} | ||||
| 			/>, | ||||
| 		); | ||||
| 		await showInstalledTab(); | ||||
| 
 | ||||
| 		// Initially, no plugins should be visible.
 | ||||
| 		expect(screen.queryByText(/^ABC Sheet Music/)).toBeNull(); | ||||
| @@ -191,5 +168,103 @@ describe('PluginStates', () => { | ||||
| 		await act(() => PluginService.instance().uninstallPlugin(testPluginId1)); | ||||
| 		expect(await screen.findByText(/^A test plugin/)).toBeVisible(); | ||||
| 		expect(screen.queryByText(/^ABC Sheet Music/)).toBeNull(); | ||||
| 
 | ||||
| 		wrapper.unmount(); | ||||
| 	}); | ||||
| 
 | ||||
| 	it('should support disabling plugins from the info modal', async () => { | ||||
| 		const abcPluginId = 'org.joplinapp.plugins.AbcSheetMusic'; | ||||
| 		const defaultPluginSettings: PluginSettings = { [abcPluginId]: defaultPluginSetting() }; | ||||
| 
 | ||||
| 		await loadMockPlugin(abcPluginId, 'ABC Sheet Music', '1.2.3', defaultPluginSettings); | ||||
| 		expect(PluginService.instance().plugins[abcPluginId]).toBeTruthy(); | ||||
| 
 | ||||
| 		const wrapper = render( | ||||
| 			<WrappedPluginStates | ||||
| 				initialPluginSettings={defaultPluginSettings} | ||||
| 				store={reduxStore} | ||||
| 			/>, | ||||
| 		); | ||||
| 		await showInstalledTab(); | ||||
| 
 | ||||
| 		const card = await screen.findByText('ABC Sheet Music'); | ||||
| 		const user = userEvent.setup(); | ||||
| 
 | ||||
| 		// Open the plugin dialog
 | ||||
| 		await user.press(card); | ||||
| 
 | ||||
| 		const enabledSwitch = await screen.findByLabelText('Enabled'); | ||||
| 		expect(enabledSwitch).toBeVisible(); | ||||
| 
 | ||||
| 		// Use fireEvent instead of userEvent.press -- .press doesn't seem to work
 | ||||
| 		// for Switches. Similar issue: https://github.com/callstack/react-native-testing-library/issues/518.
 | ||||
| 		fireEvent(enabledSwitch, 'valueChange', false); | ||||
| 
 | ||||
| 		// The plugin should now be disabled
 | ||||
| 		await waitFor(() => { | ||||
| 			expect(Setting.value('plugins.states')).toMatchObject({ | ||||
| 				[abcPluginId]: { enabled: false }, | ||||
| 			}); | ||||
| 		}); | ||||
| 
 | ||||
| 		wrapper.unmount(); | ||||
| 	}); | ||||
| 
 | ||||
| 	it('should support updating plugins from the info modal', async () => { | ||||
| 		await mockRepositoryApiConstructor(); | ||||
| 
 | ||||
| 		const abcPluginId = 'org.joplinapp.plugins.AbcSheetMusic'; | ||||
| 
 | ||||
| 		const defaultPluginSettings: PluginSettings = { | ||||
| 			[abcPluginId]: defaultPluginSetting(), | ||||
| 		}; | ||||
| 
 | ||||
| 		// Load an outdated recommended plugin
 | ||||
| 		await loadMockPlugin(abcPluginId, 'ABC Sheet Music', '0.0.1', defaultPluginSettings); | ||||
| 		expect(PluginService.instance().plugins[abcPluginId]).toBeTruthy(); | ||||
| 
 | ||||
| 		const wrapper = render( | ||||
| 			<WrappedPluginStates | ||||
| 				initialPluginSettings={defaultPluginSettings} | ||||
| 				store={reduxStore} | ||||
| 			/>, | ||||
| 		); | ||||
| 		await showInstalledTab(); | ||||
| 
 | ||||
| 		// Open the plugin dialog
 | ||||
| 		const card = await screen.findByText('ABC Sheet Music'); | ||||
| 		const user = userEvent.setup(); | ||||
| 		await user.press(card); | ||||
| 
 | ||||
| 		const updateButton = await screen.findByRole('button', { name: 'Update' }); | ||||
| 		expect(updateButton).toBeVisible(); | ||||
| 		await user.press(updateButton); | ||||
| 
 | ||||
| 		// After updating, the update button should read "updated"
 | ||||
| 		const updatedButton = await screen.findByRole('button', { name: 'Updated', disabled: true, timeout: 8000 }); | ||||
| 		expect(updatedButton).toBeVisible(); | ||||
| 
 | ||||
| 		// Should be marked as updated.
 | ||||
| 		await waitFor(() => { | ||||
| 			expect(Setting.value('plugins.states')).toMatchObject({ | ||||
| 				[abcPluginId]: { enabled: true, hasBeenUpdated: true }, | ||||
| 			}); | ||||
| 		}); | ||||
| 
 | ||||
| 		// Simulate the behavior of the plugin loader -- unloading and reloading plugins is generally
 | ||||
| 		// handled elsewhere. This does, however, help verify that the verison number changes correctly
 | ||||
| 		// in the UI.
 | ||||
| 		await act(async () => { | ||||
| 			await PluginService.instance().unloadPlugin(abcPluginId); | ||||
| 			await loadMockPlugin(abcPluginId, 'ABC Sheet Music', '0.0.2', defaultPluginSettings); | ||||
| 		}); | ||||
| 
 | ||||
| 		// Version should change in two places -- the plugin list and the modal.
 | ||||
| 		await waitFor(() => { | ||||
| 			const versionText = screen.getAllByText('v0.0.2'); | ||||
| 			expect(versionText).toHaveLength(2); | ||||
| 		}); | ||||
| 
 | ||||
| 		wrapper.unmount(); | ||||
| 	}); | ||||
| }); | ||||
| @@ -1,37 +1,16 @@ | ||||
| import * as React from 'react'; | ||||
| import RepositoryApi, { InstallMode } from '@joplin/lib/services/plugins/RepositoryApi'; | ||||
| import { mockMobilePlatform, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils'; | ||||
| 
 | ||||
| import { render, screen, userEvent, waitFor } from '@testing-library/react-native'; | ||||
| import '@testing-library/react-native/extend-expect'; | ||||
| 
 | ||||
| import SearchPlugins from './SearchPlugins'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import pluginServiceSetup from './testUtils/pluginServiceSetup'; | ||||
| import newRepoApi from './testUtils/newRepoApi'; | ||||
| import createMockReduxStore from '../../../../utils/testing/createMockReduxStore'; | ||||
| 
 | ||||
| interface WrapperProps { | ||||
| 	repoApi: RepositoryApi; | ||||
| 	repoApiInitialized?: boolean; | ||||
| 	pluginSettings?: PluginSettings; | ||||
| 	onUpdatePluginStates?: (states: PluginSettings)=> void; | ||||
| } | ||||
| 
 | ||||
| const noOpFunction = ()=>{}; | ||||
| 
 | ||||
| const SearchWrapper = (props: WrapperProps) => { | ||||
| 	return ( | ||||
| 		<SearchPlugins | ||||
| 			themeId={Setting.THEME_LIGHT} | ||||
| 			pluginSettings={props.pluginSettings ?? {}} | ||||
| 			repoApiInitialized={props.repoApiInitialized ?? true} | ||||
| 			repoApi={props.repoApi} | ||||
| 			onUpdatePluginStates={props.onUpdatePluginStates ?? noOpFunction} | ||||
| 		/> | ||||
| 	); | ||||
| }; | ||||
| import WrappedPluginStates from './testUtils/WrappedPluginStates'; | ||||
| import { AppState } from '../../../../utils/types'; | ||||
| import { Store } from 'redux'; | ||||
| import mockRepositoryApiConstructor from './testUtils/mockRepositoryApiConstructor'; | ||||
| import { resetRepoApi } from './utils/useRepoApi'; | ||||
| 
 | ||||
| const expectSearchResultCountToBe = async (count: number) => { | ||||
| 	await waitFor(() => { | ||||
| @@ -39,24 +18,43 @@ const expectSearchResultCountToBe = async (count: number) => { | ||||
| 	}); | ||||
| }; | ||||
| 
 | ||||
| describe('SearchPlugins', () => { | ||||
| const showSearchTab = async () => { | ||||
| 	const searchAccordion = await screen.findByText('Install new plugins'); | ||||
| 	await userEvent.press(searchAccordion); | ||||
| }; | ||||
| 
 | ||||
| // The search box is initially read-only -- waits for it to be editable.
 | ||||
| const getEditableSearchBox = async () => { | ||||
| 	const searchBox = await screen.findByPlaceholderText('Search plugins'); | ||||
| 	expect(searchBox).toBeVisible(); | ||||
| 
 | ||||
| 	await waitFor(() => { | ||||
| 		expect(searchBox.props.editable).toBe(true); | ||||
| 	}); | ||||
| 
 | ||||
| 	return searchBox; | ||||
| }; | ||||
| 
 | ||||
| let reduxStore: Store<AppState>; | ||||
| 
 | ||||
| describe('PluginStates.search', () => { | ||||
| 	beforeEach(async () => { | ||||
| 		await setupDatabaseAndSynchronizer(0); | ||||
| 		await switchClient(0); | ||||
| 		pluginServiceSetup(createMockReduxStore()); | ||||
| 		reduxStore = createMockReduxStore(); | ||||
| 		pluginServiceSetup(reduxStore); | ||||
| 		mockMobilePlatform('android'); | ||||
| 		resetRepoApi(); | ||||
| 
 | ||||
| 		await mockRepositoryApiConstructor(); | ||||
| 	}); | ||||
| 
 | ||||
| 	it('should find results', async () => { | ||||
| 		const repoApi = await newRepoApi(InstallMode.Default); | ||||
| 		render(<SearchWrapper repoApi={repoApi}/>); | ||||
| 
 | ||||
| 		const searchBox = screen.queryByPlaceholderText('Search'); | ||||
| 		expect(searchBox).toBeVisible(); | ||||
| 
 | ||||
| 		// No plugin cards should be visible by default
 | ||||
| 		expect(screen.queryAllByTestId('plugin-card')).toHaveLength(0); | ||||
| 		const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>); | ||||
| 
 | ||||
| 		const user = userEvent.setup(); | ||||
| 		await showSearchTab(); | ||||
| 		const searchBox = await getEditableSearchBox(); | ||||
| 		await user.type(searchBox, 'backlinks'); | ||||
| 
 | ||||
| 		// Should find one result
 | ||||
| @@ -71,19 +69,27 @@ describe('SearchPlugins', () => { | ||||
| 		await waitFor(() => { | ||||
| 			expect(screen.queryAllByTestId('plugin-card').length).toBeGreaterThan(2); | ||||
| 		}); | ||||
| 
 | ||||
| 		wrapper.unmount(); | ||||
| 	}); | ||||
| 
 | ||||
| 	it('should only show recommended plugin search results on iOS-like environments', async () => { | ||||
| 		// iOS uses restricted install mode
 | ||||
| 		const repoApi = await newRepoApi(InstallMode.Restricted); | ||||
| 		render(<SearchWrapper repoApi={repoApi}/>); | ||||
| 		mockMobilePlatform('ios'); | ||||
| 		await mockRepositoryApiConstructor(); | ||||
| 
 | ||||
| 		const searchBox = screen.queryByPlaceholderText('Search'); | ||||
| 		expect(searchBox).toBeVisible(); | ||||
| 		const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>); | ||||
| 
 | ||||
| 		const user = userEvent.setup(); | ||||
| 		await showSearchTab(); | ||||
| 
 | ||||
| 		const searchBox = await getEditableSearchBox(); | ||||
| 
 | ||||
| 		await user.press(searchBox); | ||||
| 		await user.type(searchBox, 'abc'); | ||||
| 
 | ||||
| 		expect(searchBox.props.value).toBe('abc'); | ||||
| 
 | ||||
| 		// Should find recommended plugins
 | ||||
| 		await expectSearchResultCountToBe(1); | ||||
| 
 | ||||
| @@ -97,16 +103,20 @@ describe('SearchPlugins', () => { | ||||
| 		await expectSearchResultCountToBe(1); | ||||
| 		expect(screen.getByText(/ABC Sheet Music/i)).toBeTruthy(); | ||||
| 		expect(screen.queryByText(/backlink/i)).toBeNull(); | ||||
| 
 | ||||
| 		wrapper.unmount(); | ||||
| 	}); | ||||
| 
 | ||||
| 	it('should mark incompatible plugins as incompatible', async () => { | ||||
| 		const mock = mockMobilePlatform('android'); | ||||
| 		const repoApi = await newRepoApi(InstallMode.Default); | ||||
| 		render(<SearchWrapper repoApi={repoApi}/>); | ||||
| 		const wrapper = render(<WrappedPluginStates initialPluginSettings={{}} store={reduxStore}/>); | ||||
| 
 | ||||
| 		const searchBox = screen.queryByPlaceholderText('Search'); | ||||
| 		const user = userEvent.setup(); | ||||
| 		await showSearchTab(); | ||||
| 
 | ||||
| 		const searchBox = await getEditableSearchBox(); | ||||
| 		await user.press(searchBox); | ||||
| 		await user.type(searchBox, 'abc'); | ||||
| 		expect(searchBox.props.value).toBe('abc'); | ||||
| 
 | ||||
| 		await expectSearchResultCountToBe(1); | ||||
| 		expect(screen.queryByText('Incompatible')).toBeNull(); | ||||
| @@ -117,6 +127,6 @@ describe('SearchPlugins', () => { | ||||
| 		expect(await screen.findByText(/Note list and side bar/i)).toBeVisible(); | ||||
| 		expect(await screen.findByText('Incompatible')).toBeVisible(); | ||||
| 
 | ||||
| 		mock.reset(); | ||||
| 		wrapper.unmount(); | ||||
| 	}); | ||||
| }); | ||||
| @@ -1,17 +1,17 @@ | ||||
| import * as React from 'react'; | ||||
| import { useCallback, useState } from 'react'; | ||||
| import { useCallback, useEffect, useMemo, useState } from 'react'; | ||||
| import { ConfigScreenStyles } from '../configScreenStyles'; | ||||
| import { View } from 'react-native'; | ||||
| import { Banner, Button, Text } from 'react-native-paper'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { View, StyleSheet } from 'react-native'; | ||||
| import { Banner, Text, Button, ProgressBar, List, Divider } from 'react-native-paper'; | ||||
| import { _, _n } from '@joplin/lib/locale'; | ||||
| import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import PluginToggle from './PluginToggle'; | ||||
| import InstalledPluginBox from './InstalledPluginBox'; | ||||
| import SearchPlugins from './SearchPlugins'; | ||||
| import { ItemEvent } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import NavService from '@joplin/lib/services/NavService'; | ||||
| import { ItemEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import useRepoApi from './utils/useRepoApi'; | ||||
| import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi'; | ||||
| import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; | ||||
| import PluginInfoModal from './PluginInfoModal'; | ||||
| import usePluginCallbacks from './utils/usePluginCallbacks'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| @@ -43,21 +43,33 @@ const useLoadedPluginIds = () => { | ||||
| 	}, []); | ||||
| 	const [loadedPluginIds, setLoadedPluginIds] = useState(getLoadedPlugins); | ||||
|  | ||||
| 	useAsyncEffect(async event => { | ||||
| 		while (!event.cancelled) { | ||||
| 			await PluginService.instance().waitForLoadedPluginsChange(); | ||||
| 	useEffect(() => { | ||||
| 		const { remove } = PluginService.instance().addLoadedPluginsChangeListener(() => { | ||||
| 			setLoadedPluginIds(getLoadedPlugins()); | ||||
| 		} | ||||
| 	}, []); | ||||
| 		}); | ||||
|  | ||||
| 		return () => { | ||||
| 			remove(); | ||||
| 		}; | ||||
| 	}, [getLoadedPlugins]); | ||||
|  | ||||
| 	return loadedPluginIds; | ||||
| }; | ||||
|  | ||||
| const styles = StyleSheet.create({ | ||||
| 	installedPluginsContainer: { | ||||
| 		marginLeft: 8, | ||||
| 		marginRight: 8, | ||||
| 		marginBottom: 10, | ||||
| 	}, | ||||
| }); | ||||
|  | ||||
| const PluginStates: React.FC<Props> = props => { | ||||
| 	const [repoApiError, setRepoApiError] = useState(null); | ||||
| 	const [repoApiLoaded, setRepoApiLoaded] = useState(false); | ||||
| 	const [reloadRepoCounter, setRepoReloadCounter] = useState(0); | ||||
| 	const [updatablePluginIds, setUpdatablePluginIds] = useState<Record<string, boolean>>({}); | ||||
| 	const [shownInDialogItem, setShownInDialogItem] = useState<PluginItem|null>(null); | ||||
|  | ||||
| 	const onRepoApiLoaded = useCallback(async (repoApi: RepositoryApi) => { | ||||
| 		const manifests = Object.values(PluginService.instance().plugins) | ||||
| @@ -98,15 +110,26 @@ const PluginStates: React.FC<Props> = props => { | ||||
| 				<Button onPress={reloadPluginRepo}>{_('Retry')}</Button> | ||||
| 			</View>; | ||||
| 		} else { | ||||
| 			return <Text>{_('Loading plugin repository...')}</Text>; | ||||
| 			return <ProgressBar accessibilityLabel={_('Loading...')} indeterminate={true} />; | ||||
| 		} | ||||
| 	}; | ||||
|  | ||||
| 	const onShowPluginLog = useCallback((event: ItemEvent) => { | ||||
| 		const pluginId = event.item.manifest.id; | ||||
| 		void NavService.go('Log', { defaultFilter: pluginId }); | ||||
| 	const onShowPluginInfo = useCallback((event: ItemEvent) => { | ||||
| 		setShownInDialogItem(event.item); | ||||
| 	}, []); | ||||
|  | ||||
| 	const onPluginDialogClosed = useCallback(() => { | ||||
| 		setShownInDialogItem(null); | ||||
| 	}, []); | ||||
|  | ||||
| 	const pluginSettings = useMemo(() => { | ||||
| 		return PluginService.instance().unserializePluginSettings(props.pluginSettings); | ||||
| 	}, [props.pluginSettings]); | ||||
|  | ||||
| 	const { callbacks: pluginCallbacks, updatingPluginIds, installingPluginIds } = usePluginCallbacks({ | ||||
| 		pluginSettings, updatePluginStates: props.updatePluginStates, repoApi, | ||||
| 	}); | ||||
|  | ||||
| 	const installedPluginCards = []; | ||||
| 	const pluginService = PluginService.instance(); | ||||
|  | ||||
| @@ -116,16 +139,16 @@ const PluginStates: React.FC<Props> = props => { | ||||
|  | ||||
| 		if (!props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(plugin.manifest.name)) { | ||||
| 			installedPluginCards.push( | ||||
| 				<PluginToggle | ||||
| 				<InstalledPluginBox | ||||
| 					key={`plugin-${pluginId}`} | ||||
| 					themeId={props.themeId} | ||||
| 					pluginId={pluginId} | ||||
| 					styles={props.styles} | ||||
| 					pluginSettings={props.pluginSettings} | ||||
| 					pluginSettings={pluginSettings} | ||||
| 					updatablePluginIds={updatablePluginIds} | ||||
| 					updatePluginStates={props.updatePluginStates} | ||||
| 					onShowPluginLog={onShowPluginLog} | ||||
| 					repoApi={repoApi} | ||||
| 					updatingPluginIds={updatingPluginIds} | ||||
| 					showInstalledChip={false} | ||||
| 					onShowPluginInfo={onShowPluginInfo} | ||||
| 					callbacks={pluginCallbacks} | ||||
| 				/>, | ||||
| 			); | ||||
| 		} | ||||
| @@ -135,21 +158,65 @@ const PluginStates: React.FC<Props> = props => { | ||||
| 		!props.shouldShowBasedOnSearchQuery || props.shouldShowBasedOnSearchQuery(searchInputSearchText()) | ||||
| 	); | ||||
|  | ||||
| 	const searchComponent = ( | ||||
| 		<SearchPlugins | ||||
| 			pluginSettings={props.pluginSettings} | ||||
| 			themeId={props.themeId} | ||||
| 			onUpdatePluginStates={props.updatePluginStates} | ||||
| 			repoApiInitialized={repoApiLoaded} | ||||
| 			repoApi={repoApi} | ||||
| 		/> | ||||
| 	const [searchQuery, setSearchQuery] = useState(''); | ||||
|  | ||||
| 	const searchAccordion = ( | ||||
| 		<List.Accordion | ||||
| 			title={_('Install new plugins')} | ||||
| 			description={_('Browse and install community plugins.')} | ||||
| 			id='search' | ||||
| 		> | ||||
| 			<SearchPlugins | ||||
| 				pluginSettings={pluginSettings} | ||||
| 				themeId={props.themeId} | ||||
| 				onUpdatePluginStates={props.updatePluginStates} | ||||
| 				installingPluginIds={installingPluginIds} | ||||
| 				callbacks={pluginCallbacks} | ||||
| 				repoApiInitialized={repoApiLoaded} | ||||
| 				repoApi={repoApi} | ||||
| 				updatingPluginIds={updatingPluginIds} | ||||
| 				updatablePluginIds={updatablePluginIds} | ||||
| 				onShowPluginInfo={onShowPluginInfo} | ||||
|  | ||||
| 				searchQuery={searchQuery} | ||||
| 				setSearchQuery={setSearchQuery} | ||||
| 			/> | ||||
| 		</List.Accordion> | ||||
| 	); | ||||
|  | ||||
| 	const isSearching = !!props.shouldShowBasedOnSearchQuery; | ||||
| 	// Don't include the number of installed plugins when searching -- only a few of the total | ||||
| 	// may be shown by the search. | ||||
| 	const installedAccordionDescription = !isSearching ? _n('You currently have %d plugin installed.', 'You currently have %d plugins installed.', pluginIds.length, pluginIds.length) : null; | ||||
|  | ||||
| 	return ( | ||||
| 		<View> | ||||
| 			{renderRepoApiStatus()} | ||||
| 			{installedPluginCards} | ||||
| 			{showSearch ? searchComponent : null} | ||||
| 			<List.AccordionGroup> | ||||
| 				<List.Accordion | ||||
| 					title={_('Installed plugins')} | ||||
| 					description={installedAccordionDescription} | ||||
| 					id='installed' | ||||
| 				> | ||||
| 					<View style={styles.installedPluginsContainer}> | ||||
| 						{installedPluginCards} | ||||
| 					</View> | ||||
| 				</List.Accordion> | ||||
| 				<Divider/> | ||||
| 				{showSearch ? searchAccordion : null} | ||||
| 				<Divider/> | ||||
| 			</List.AccordionGroup> | ||||
| 			<PluginInfoModal | ||||
| 				themeId={props.themeId} | ||||
| 				pluginSettings={pluginSettings} | ||||
| 				updatablePluginIds={updatablePluginIds} | ||||
| 				updatingPluginIds={updatingPluginIds} | ||||
| 				installingPluginIds={installingPluginIds} | ||||
| 				item={shownInDialogItem} | ||||
| 				visible={!!shownInDialogItem} | ||||
| 				onModalDismiss={onPluginDialogClosed} | ||||
| 				pluginCallbacks={pluginCallbacks} | ||||
| 			/> | ||||
| 		</View> | ||||
| 	); | ||||
| }; | ||||
|   | ||||
| @@ -1,108 +0,0 @@ | ||||
|  | ||||
| import * as React from 'react'; | ||||
| import { ConfigScreenStyles } from '../configScreenStyles'; | ||||
| import PluginService, { PluginSettings, defaultPluginSetting, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import { useCallback, useMemo, useState } from 'react'; | ||||
| import PluginBox, { UpdateState } from './PluginBox'; | ||||
| import useOnDeleteHandler from '@joplin/lib/components/shared/config/plugins/useOnDeleteHandler'; | ||||
| import { ItemEvent, OnPluginSettingChangeEvent } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import useOnInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler'; | ||||
| import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi'; | ||||
|  | ||||
| interface Props { | ||||
| 	pluginId: string; | ||||
| 	themeId: number; | ||||
| 	styles: ConfigScreenStyles; | ||||
| 	pluginSettings: SerializedPluginSettings; | ||||
| 	updatablePluginIds: Record<string, boolean>; | ||||
| 	repoApi: RepositoryApi; | ||||
|  | ||||
| 	onShowPluginLog: (event: ItemEvent)=> void; | ||||
| 	updatePluginStates: (settingValue: PluginSettings)=> void; | ||||
| } | ||||
|  | ||||
| const PluginToggle: React.FC<Props> = props => { | ||||
| 	const pluginService = useMemo(() => PluginService.instance(), []); | ||||
| 	const plugin = useMemo(() => { | ||||
| 		return pluginService.pluginById(props.pluginId); | ||||
| 	}, [pluginService, props.pluginId]); | ||||
|  | ||||
| 	const pluginSettings = useMemo(() => { | ||||
| 		const settings = { ...pluginService.unserializePluginSettings(props.pluginSettings) }; | ||||
|  | ||||
| 		if (!settings[props.pluginId]) { | ||||
| 			settings[props.pluginId] = defaultPluginSetting(); | ||||
| 		} | ||||
|  | ||||
| 		return settings; | ||||
| 	}, [props.pluginSettings, pluginService, props.pluginId]); | ||||
|  | ||||
| 	const onPluginSettingsChange = useCallback((event: OnPluginSettingChangeEvent) => { | ||||
| 		props.updatePluginStates(event.value); | ||||
| 	}, [props.updatePluginStates]); | ||||
|  | ||||
| 	const updatePluginEnabled = useCallback((enabled: boolean) => { | ||||
| 		const newSettings = { ...pluginSettings }; | ||||
| 		newSettings[props.pluginId].enabled = enabled; | ||||
|  | ||||
| 		props.updatePluginStates(newSettings); | ||||
| 	}, [pluginSettings, props.pluginId, props.updatePluginStates]); | ||||
|  | ||||
| 	const pluginId = plugin.manifest.id; | ||||
| 	const onToggle = useCallback(() => { | ||||
| 		const settings = pluginSettings[pluginId]; | ||||
| 		updatePluginEnabled(!settings.enabled); | ||||
| 	}, [pluginSettings, updatePluginEnabled, pluginId]); | ||||
|  | ||||
| 	const onDelete = useOnDeleteHandler(pluginSettings, onPluginSettingsChange, true); | ||||
|  | ||||
| 	const [updatingPluginIds, setUpdatingPluginIds] = useState<Record<string, boolean>>({}); | ||||
| 	const onUpdate = useOnInstallHandler(setUpdatingPluginIds, pluginSettings, props.repoApi, onPluginSettingsChange, true); | ||||
|  | ||||
| 	const updateState = useMemo(() => { | ||||
| 		const settings = pluginSettings[pluginId]; | ||||
|  | ||||
| 		if (settings.hasBeenUpdated) { | ||||
| 			return UpdateState.HasBeenUpdated; | ||||
| 		} | ||||
| 		if (updatingPluginIds[pluginId]) { | ||||
| 			return UpdateState.Updating; | ||||
| 		} | ||||
| 		if (props.updatablePluginIds[pluginId]) { | ||||
| 			return UpdateState.CanUpdate; | ||||
| 		} | ||||
| 		return UpdateState.Idle; | ||||
| 	}, [pluginSettings, updatingPluginIds, pluginId, props.updatablePluginIds]); | ||||
|  | ||||
| 	const pluginItem = useMemo(() => { | ||||
| 		const settings = pluginSettings[pluginId]; | ||||
| 		return { | ||||
| 			manifest: plugin.manifest, | ||||
| 			enabled: settings.enabled, | ||||
| 			deleted: settings.deleted, | ||||
| 			devMode: plugin.devMode, | ||||
| 			builtIn: plugin.builtIn, | ||||
| 			hasBeenUpdated: settings.hasBeenUpdated, | ||||
| 		}; | ||||
| 	}, [plugin, pluginId, pluginSettings]); | ||||
|  | ||||
| 	const isCompatible = useMemo(() => { | ||||
| 		return PluginService.instance().isCompatible(plugin.manifest); | ||||
| 	}, [plugin]); | ||||
|  | ||||
| 	return ( | ||||
| 		<PluginBox | ||||
| 			themeId={props.themeId} | ||||
| 			item={pluginItem} | ||||
| 			isCompatible={isCompatible} | ||||
| 			hasErrors={plugin.hasErrors} | ||||
| 			onShowPluginLog={props.onShowPluginLog} | ||||
| 			onToggle={onToggle} | ||||
| 			onDelete={onDelete} | ||||
| 			onUpdate={onUpdate} | ||||
| 			updateState={updateState} | ||||
| 		/> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| export default PluginToggle; | ||||
| @@ -3,18 +3,20 @@ import { _ } from '@joplin/lib/locale'; | ||||
| import PluginService, { PluginSettings, SerializedPluginSettings, defaultPluginSetting } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import * as React from 'react'; | ||||
| import { useCallback, useState } from 'react'; | ||||
| import { Button } from 'react-native-paper'; | ||||
| import pickDocument from '../../../../utils/pickDocument'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import { Platform } from 'react-native'; | ||||
| import { Platform, View, ViewStyle } from 'react-native'; | ||||
| import { join, extname } from 'path'; | ||||
| import uuid from '@joplin/lib/uuid'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import TextButton, { ButtonType } from '../../../buttons/TextButton'; | ||||
| import { ConfigScreenStyles } from '../configScreenStyles'; | ||||
|  | ||||
| interface Props { | ||||
| 	updatePluginStates: (settingValue: PluginSettings)=> void; | ||||
| 	pluginSettings: SerializedPluginSettings; | ||||
| 	styles: ConfigScreenStyles; | ||||
| } | ||||
|  | ||||
| const logger = Logger.create('PluginUploadButton'); | ||||
| @@ -26,6 +28,8 @@ export const canInstallPluginsFromFile = () => { | ||||
| 	return shim.mobilePlatform() !== 'ios' || Setting.value('env') === 'dev'; | ||||
| }; | ||||
|  | ||||
| const buttonStyle: ViewStyle = { flexGrow: 1 }; | ||||
|  | ||||
| const PluginUploadButton: React.FC<Props> = props => { | ||||
| 	const [showLoadingAnimation, setShowLoadingAnimation] = useState(false); | ||||
|  | ||||
| @@ -85,13 +89,17 @@ const PluginUploadButton: React.FC<Props> = props => { | ||||
| 	}, [props.pluginSettings, props.updatePluginStates]); | ||||
|  | ||||
| 	return ( | ||||
| 		<Button | ||||
| 			onPress={onInstallFromFile} | ||||
| 			disabled={showLoadingAnimation || !canInstallPluginsFromFile()} | ||||
| 			loading={showLoadingAnimation} | ||||
| 		> | ||||
| 			{buttonLabel()} | ||||
| 		</Button> | ||||
| 		<View style={props.styles.getContainerStyle(false)}> | ||||
| 			<TextButton | ||||
| 				type={ButtonType.Primary} | ||||
| 				onPress={onInstallFromFile} | ||||
| 				style={buttonStyle} | ||||
| 				disabled={showLoadingAnimation || !canInstallPluginsFromFile()} | ||||
| 				loading={showLoadingAnimation} | ||||
| 			> | ||||
| 				{buttonLabel()} | ||||
| 			</TextButton> | ||||
| 		</View> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -4,21 +4,32 @@ import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; | ||||
| import { useCallback, useMemo, useState } from 'react'; | ||||
| import { FlatList, View } from 'react-native'; | ||||
| import { Searchbar } from 'react-native-paper'; | ||||
| import { FlatList, StyleSheet, View } from 'react-native'; | ||||
| import { TextInput, Text } from 'react-native-paper'; | ||||
| import PluginBox, { InstallState } from './PluginBox'; | ||||
| import PluginService, { PluginSettings, SerializedPluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import useInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler'; | ||||
| import { OnPluginSettingChangeEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi'; | ||||
| import openWebsiteForPlugin from './utils/openWebsiteForPlugin'; | ||||
| import { PluginCallback, PluginCallbacks } from './utils/usePluginCallbacks'; | ||||
| import InstalledPluginBox from './InstalledPluginBox'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| 	pluginSettings: SerializedPluginSettings; | ||||
| 	pluginSettings: PluginSettings; | ||||
| 	repoApiInitialized: boolean; | ||||
| 	onUpdatePluginStates: (states: PluginSettings)=> void; | ||||
| 	repoApi: RepositoryApi; | ||||
|  | ||||
| 	installingPluginIds: Record<string, boolean>; | ||||
| 	updatingPluginIds: Record<string, boolean>; | ||||
| 	updatablePluginIds: Record<string, boolean>; | ||||
|  | ||||
| 	callbacks: PluginCallbacks; | ||||
| 	onShowPluginInfo: PluginCallback; | ||||
|  | ||||
| 	searchQuery: string; | ||||
| 	setSearchQuery: (newQuery: string)=> void; | ||||
| } | ||||
|  | ||||
| interface SearchResultRecord { | ||||
| @@ -27,8 +38,20 @@ interface SearchResultRecord { | ||||
| 	installState: InstallState; | ||||
| } | ||||
|  | ||||
| const styles = StyleSheet.create({ | ||||
| 	container: { | ||||
| 		flexDirection: 'column', | ||||
| 		margin: 12, | ||||
| 	}, | ||||
| 	resultsCounter: { | ||||
| 		margin: 12, | ||||
| 		marginTop: 17, | ||||
| 		marginBottom: 4, | ||||
| 	}, | ||||
| }); | ||||
|  | ||||
| const PluginSearch: React.FC<Props> = props => { | ||||
| 	const [searchQuery, setSearchQuery] = useState(''); | ||||
| 	const { searchQuery, setSearchQuery } = props; | ||||
| 	const [searchResultManifests, setSearchResultManifests] = useState<PluginManifest[]>([]); | ||||
|  | ||||
| 	useAsyncEffect(async event => { | ||||
| @@ -42,8 +65,6 @@ const PluginSearch: React.FC<Props> = props => { | ||||
| 		} | ||||
| 	}, [searchQuery, props.repoApi, setSearchResultManifests, props.repoApiInitialized]); | ||||
|  | ||||
| 	const [installingPluginsIds, setInstallingPluginIds] = useState<Record<string, boolean>>({}); | ||||
|  | ||||
| 	const pluginSettings = useMemo(() => { | ||||
| 		return { ...PluginService.instance().unserializePluginSettings(props.pluginSettings) }; | ||||
| 	}, [props.pluginSettings]); | ||||
| @@ -56,12 +77,13 @@ const PluginSearch: React.FC<Props> = props => { | ||||
| 			if (settings && !settings.deleted) { | ||||
| 				installState = InstallState.Installed; | ||||
| 			} | ||||
| 			if (installingPluginsIds[manifest.id]) { | ||||
| 			if (props.installingPluginIds[manifest.id]) { | ||||
| 				installState = InstallState.Installing; | ||||
| 			} | ||||
|  | ||||
| 			const item: PluginItem = { | ||||
| 				manifest, | ||||
| 				installed: !!settings, | ||||
| 				enabled: settings && settings.enabled, | ||||
| 				deleted: settings && !settings.deleted, | ||||
| 				devMode: false, | ||||
| @@ -75,41 +97,62 @@ const PluginSearch: React.FC<Props> = props => { | ||||
| 				installState, | ||||
| 			}; | ||||
| 		}); | ||||
| 	}, [searchResultManifests, installingPluginsIds, pluginSettings]); | ||||
| 	}, [searchResultManifests, props.installingPluginIds, pluginSettings]); | ||||
|  | ||||
| 	const onPluginSettingsChange = useCallback((event: OnPluginSettingChangeEvent) => { | ||||
| 		props.onUpdatePluginStates(event.value); | ||||
| 	}, [props.onUpdatePluginStates]); | ||||
|  | ||||
| 	const installPlugin = useInstallHandler( | ||||
| 		setInstallingPluginIds, pluginSettings, props.repoApi, onPluginSettingsChange, false, | ||||
| 	); | ||||
|  | ||||
| 	const onInstall = props.callbacks.onInstall; | ||||
| 	const renderResult = useCallback(({ item }: { item: SearchResultRecord }) => { | ||||
| 		const manifest = item.item.manifest; | ||||
|  | ||||
| 		return ( | ||||
| 			<PluginBox | ||||
| 				themeId={props.themeId} | ||||
| 				key={manifest.id} | ||||
| 				item={item.item} | ||||
| 				installState={item.installState} | ||||
| 				isCompatible={PluginService.instance().isCompatible(manifest)} | ||||
| 				onInstall={installPlugin} | ||||
| 				onAboutPress={openWebsiteForPlugin} | ||||
| 			/> | ||||
| 		); | ||||
| 	}, [installPlugin, props.themeId]); | ||||
| 		if (item.installState === InstallState.Installed && PluginService.instance().isPluginLoaded(manifest.id)) { | ||||
| 			return ( | ||||
| 				<InstalledPluginBox | ||||
| 					pluginId={manifest.id} | ||||
| 					themeId={props.themeId} | ||||
| 					pluginSettings={props.pluginSettings} | ||||
| 					updatablePluginIds={props.updatablePluginIds} | ||||
| 					updatingPluginIds={props.updatingPluginIds} | ||||
| 					showInstalledChip={true} | ||||
| 					callbacks={props.callbacks} | ||||
| 					onShowPluginInfo={props.onShowPluginInfo} | ||||
| 				/> | ||||
| 			); | ||||
| 		} else { | ||||
| 			return ( | ||||
| 				<PluginBox | ||||
| 					themeId={props.themeId} | ||||
| 					key={manifest.id} | ||||
| 					item={item.item} | ||||
| 					installState={item.installState} | ||||
| 					showInstalledChip={false} | ||||
| 					isCompatible={PluginService.instance().isCompatible(manifest)} | ||||
| 					onInstall={onInstall} | ||||
| 					onAboutPress={openWebsiteForPlugin} | ||||
| 				/> | ||||
| 			); | ||||
| 		} | ||||
| 	}, [onInstall, props.themeId, props.pluginSettings, props.updatingPluginIds, props.updatablePluginIds, props.onShowPluginInfo, props.callbacks]); | ||||
|  | ||||
| 	const renderResultsCount = () => { | ||||
| 		if (!searchQuery.length) return null; | ||||
|  | ||||
| 		return <Text style={styles.resultsCounter} variant='labelLarge'> | ||||
| 			{_('Results (%d):', searchResults.length)} | ||||
| 		</Text>; | ||||
| 	}; | ||||
|  | ||||
| 	return ( | ||||
| 		<View style={{ flexDirection: 'column' }}> | ||||
| 			<Searchbar | ||||
| 		<View style={styles.container}> | ||||
| 			<TextInput | ||||
| 				testID='searchbar' | ||||
| 				placeholder={_('Search')} | ||||
| 				mode='outlined' | ||||
| 				left={<TextInput.Icon icon='magnify' />} | ||||
| 				placeholder={_('Search plugins')} | ||||
| 				onChangeText={setSearchQuery} | ||||
| 				value={searchQuery} | ||||
| 				editable={props.repoApiInitialized} | ||||
| 			/> | ||||
| 			{renderResultsCount()} | ||||
| 			<FlatList | ||||
| 				data={searchResults} | ||||
| 				renderItem={renderResult} | ||||
|   | ||||
| @@ -1,12 +1,13 @@ | ||||
| import * as React from 'react'; | ||||
| import { useCallback } from 'react'; | ||||
| import { ItemEvent, PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import { Button, ButtonProps } from 'react-native-paper'; | ||||
| import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import TextButton, { TextButtonProps, ButtonType } from '../../../../buttons/TextButton'; | ||||
| import { PluginCallback } from '../utils/usePluginCallbacks'; | ||||
| 
 | ||||
| export type PluginCallback = (event: ItemEvent)=> void; | ||||
| 
 | ||||
| interface Props extends Omit<ButtonProps, 'item'|'onPress'|'children'> { | ||||
| interface Props extends Omit<TextButtonProps, 'type'|'item'|'onPress'|'children'> { | ||||
| 	item: PluginItem; | ||||
| 	type?: ButtonType; | ||||
| 	onPress?: PluginCallback; | ||||
| 	title: string; | ||||
| } | ||||
| @@ -24,11 +25,12 @@ const ActionButton: React.FC<Props> = props => { | ||||
| 	// marked as translatable.
 | ||||
| 	const accessibilityLabel = `${props.title}  ${props.item.manifest.name}`; | ||||
| 	return ( | ||||
| 		<Button | ||||
| 		<TextButton | ||||
| 			type={ButtonType.Primary} | ||||
| 			{...props} | ||||
| 			onPress={onPress} | ||||
| 			accessibilityLabel={accessibilityLabel} | ||||
| 		>{props.title}</Button> | ||||
| 		>{props.title}</TextButton> | ||||
| 	); | ||||
| }; | ||||
| 
 | ||||
| @@ -0,0 +1,34 @@ | ||||
| import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import * as React from 'react'; | ||||
| import ActionButton from './ActionButton'; | ||||
| import { PluginCallback } from '../utils/usePluginCallbacks'; | ||||
| import { InstallState } from '../PluginBox'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
|  | ||||
| interface Props { | ||||
| 	item: PluginItem; | ||||
| 	onInstall: PluginCallback; | ||||
| 	installState: InstallState; | ||||
| 	isCompatible: boolean; | ||||
| } | ||||
|  | ||||
| const InstallButton: React.FC<Props> = props => { | ||||
| 	const installButtonTitle = () => { | ||||
| 		if (props.installState === InstallState.Installing) return _('Installing...'); | ||||
| 		if (props.installState === InstallState.NotInstalled) return _('Install'); | ||||
| 		if (props.installState === InstallState.Installed) return _('Installed'); | ||||
| 		return `Invalid install state: ${props.installState}`; | ||||
| 	}; | ||||
|  | ||||
| 	return ( | ||||
| 		<ActionButton | ||||
| 			item={props.item} | ||||
| 			onPress={props.onInstall} | ||||
| 			disabled={props.installState !== InstallState.NotInstalled || !props.isCompatible} | ||||
| 			loading={props.installState === InstallState.Installing} | ||||
| 			title={installButtonTitle()} | ||||
| 		/> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| export default InstallButton; | ||||
| @@ -0,0 +1,45 @@ | ||||
| import * as React from 'react'; | ||||
| import { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import configScreenStyles from '../../configScreenStyles'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import { Provider } from 'react-redux'; | ||||
| import { Store } from 'redux'; | ||||
| import { PaperProvider } from 'react-native-paper'; | ||||
| import PluginStates from '../PluginStates'; | ||||
| import { AppState } from '../../../../../utils/types'; | ||||
| import { useCallback, useState } from 'react'; | ||||
|  | ||||
| interface WrapperProps { | ||||
| 	initialPluginSettings: PluginSettings; | ||||
| 	store: Store<AppState>; | ||||
| } | ||||
| const shouldShowBasedOnSettingSearchQuery = ()=>true; | ||||
|  | ||||
| const PluginStatesWrapper = (props: WrapperProps) => { | ||||
| 	const styles = configScreenStyles(Setting.THEME_LIGHT); | ||||
|  | ||||
| 	const [pluginSettings, setPluginSettings] = useState(() => { | ||||
| 		return props.initialPluginSettings ?? {}; | ||||
| 	}); | ||||
|  | ||||
| 	const updatePluginStates = useCallback((newStates: PluginSettings) => { | ||||
| 		setPluginSettings(newStates); | ||||
| 		Setting.setValue('plugins.states', newStates); | ||||
| 	}, []); | ||||
|  | ||||
| 	return ( | ||||
| 		<Provider store={props.store}> | ||||
| 			<PaperProvider> | ||||
| 				<PluginStates | ||||
| 					styles={styles} | ||||
| 					themeId={Setting.THEME_LIGHT} | ||||
| 					updatePluginStates={updatePluginStates} | ||||
| 					pluginSettings={pluginSettings} | ||||
| 					shouldShowBasedOnSearchQuery={shouldShowBasedOnSettingSearchQuery} | ||||
| 				/> | ||||
| 			</PaperProvider> | ||||
| 		</Provider> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| export default PluginStatesWrapper; | ||||
| @@ -0,0 +1,17 @@ | ||||
| import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi'; | ||||
| import { createTempDir, supportDir } from '@joplin/lib/testing/test-utils'; | ||||
| import { remove } from 'fs-extra'; | ||||
|  | ||||
| let repoTempDir: string|null = null; | ||||
| const mockRepositoryApiConstructor = async () => { | ||||
| 	if (repoTempDir) { | ||||
| 		await remove(repoTempDir); | ||||
| 	} | ||||
| 	repoTempDir = await createTempDir(); | ||||
|  | ||||
| 	RepositoryApi.ofDefaultJoplinRepo = jest.fn((_tempDirPath: string, appType, installMode) => { | ||||
| 		return new RepositoryApi(`${supportDir}/pluginRepo`, repoTempDir, appType, installMode); | ||||
| 	}); | ||||
| }; | ||||
|  | ||||
| export default mockRepositoryApiConstructor; | ||||
| @@ -0,0 +1,75 @@ | ||||
| import { ItemEvent, OnPluginSettingChangeEvent } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import useOnDeleteHandler from '@joplin/lib/components/shared/config/plugins/useOnDeleteHandler'; | ||||
| import useOnInstallHandler from '@joplin/lib/components/shared/config/plugins/useOnInstallHandler'; | ||||
| import NavService from '@joplin/lib/services/NavService'; | ||||
| import { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import RepositoryApi from '@joplin/lib/services/plugins/RepositoryApi'; | ||||
| import { useCallback, useMemo, useState } from 'react'; | ||||
|  | ||||
| interface Props { | ||||
| 	updatePluginStates: (settingValue: PluginSettings)=> void; | ||||
| 	pluginSettings: PluginSettings; | ||||
| 	repoApi: RepositoryApi; | ||||
| } | ||||
|  | ||||
| export type PluginCallback = (event: ItemEvent)=> void; | ||||
|  | ||||
| export interface PluginCallbacks { | ||||
| 	onToggle: PluginCallback; | ||||
| 	onUpdate: PluginCallback; | ||||
| 	onInstall: PluginCallback; | ||||
| 	onDelete: PluginCallback; | ||||
| 	onShowPluginLog: PluginCallback; | ||||
| } | ||||
|  | ||||
| const usePluginCallbacks = (props: Props) => { | ||||
| 	const onPluginSettingsChange = useCallback((event: OnPluginSettingChangeEvent) => { | ||||
| 		props.updatePluginStates(event.value); | ||||
| 	}, [props.updatePluginStates]); | ||||
|  | ||||
| 	const updatePluginEnabled = useCallback((pluginId: string, enabled: boolean) => { | ||||
| 		const newSettings = { ...props.pluginSettings }; | ||||
| 		newSettings[pluginId].enabled = enabled; | ||||
|  | ||||
| 		props.updatePluginStates(newSettings); | ||||
| 	}, [props.pluginSettings, props.updatePluginStates]); | ||||
|  | ||||
| 	const onToggle = useCallback((event: ItemEvent) => { | ||||
| 		const pluginId = event.item.manifest.id; | ||||
| 		const settings = props.pluginSettings[pluginId]; | ||||
| 		updatePluginEnabled(pluginId, !settings.enabled); | ||||
| 	}, [props.pluginSettings, updatePluginEnabled]); | ||||
|  | ||||
| 	const onDelete = useOnDeleteHandler(props.pluginSettings, onPluginSettingsChange, true); | ||||
|  | ||||
| 	const [updatingPluginIds, setUpdatingPluginIds] = useState<Record<string, boolean>>({}); | ||||
| 	const onUpdate = useOnInstallHandler(setUpdatingPluginIds, props.pluginSettings, props.repoApi, onPluginSettingsChange, true); | ||||
|  | ||||
| 	const [installingPluginIds, setInstallingPluginIds] = useState<Record<string, boolean>>({}); | ||||
| 	const onInstall = useOnInstallHandler( | ||||
| 		setInstallingPluginIds, props.pluginSettings, props.repoApi, onPluginSettingsChange, false, | ||||
| 	); | ||||
|  | ||||
| 	const onShowPluginLog = useCallback((event: ItemEvent) => { | ||||
| 		const pluginId = event.item.manifest.id; | ||||
| 		void NavService.go('Log', { defaultFilter: pluginId }); | ||||
| 	}, []); | ||||
|  | ||||
| 	const callbacks = useMemo((): PluginCallbacks => { | ||||
| 		return { | ||||
| 			onToggle, | ||||
| 			onDelete, | ||||
| 			onUpdate, | ||||
| 			onInstall, | ||||
| 			onShowPluginLog, | ||||
| 		}; | ||||
| 	}, [onToggle, onDelete, onUpdate, onInstall, onShowPluginLog]); | ||||
|  | ||||
| 	return { | ||||
| 		callbacks, | ||||
| 		updatingPluginIds, | ||||
| 		installingPluginIds, | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
| export default usePluginCallbacks; | ||||
| @@ -0,0 +1,38 @@ | ||||
| import { PluginItem } from '@joplin/lib/components/shared/config/plugins/types'; | ||||
| import { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import { PluginManifest } from '@joplin/lib/services/plugins/utils/types'; | ||||
| import { useMemo, useRef } from 'react'; | ||||
| import usePlugin from '../../../../../plugins/hooks/usePlugin'; | ||||
|  | ||||
| // initialItem is used when the plugin is not installed. For example, if the plugin item is being | ||||
| // created from search results. | ||||
| const usePluginItem = (id: string, pluginSettings: PluginSettings, initialItem: PluginItem|null): PluginItem => { | ||||
| 	const plugin = usePlugin(id); | ||||
|  | ||||
| 	const lastManifest = useRef<PluginManifest>(); | ||||
| 	if (plugin) { | ||||
| 		lastManifest.current = plugin.manifest; | ||||
| 	} else if (!lastManifest.current) { | ||||
| 		lastManifest.current = initialItem?.manifest; | ||||
| 	} | ||||
| 	const manifest = lastManifest.current; | ||||
|  | ||||
| 	return useMemo(() => { | ||||
| 		if (!manifest) return null; | ||||
| 		const settings = pluginSettings[id]; | ||||
|  | ||||
| 		return { | ||||
| 			id, | ||||
| 			manifest, | ||||
|  | ||||
| 			installed: !!settings, | ||||
| 			enabled: settings?.enabled ?? false, | ||||
| 			deleted: settings?.deleted ?? false, | ||||
| 			hasBeenUpdated: settings?.hasBeenUpdated ?? false, | ||||
| 			devMode: plugin?.devMode ?? false, | ||||
| 			builtIn: plugin?.builtIn ?? false, | ||||
| 		}; | ||||
| 	}, [plugin, id, pluginSettings, manifest]); | ||||
| }; | ||||
|  | ||||
| export default usePluginItem; | ||||
| @@ -0,0 +1,39 @@ | ||||
| import { PluginSettings } from '@joplin/lib/services/plugins/PluginService'; | ||||
| import { useMemo } from 'react'; | ||||
|  | ||||
| export enum UpdateState { | ||||
| 	Idle = 1, | ||||
| 	CanUpdate = 2, | ||||
| 	Updating = 3, | ||||
| 	HasBeenUpdated = 4, | ||||
| } | ||||
|  | ||||
| interface Props { | ||||
| 	pluginId: string; | ||||
|  | ||||
| 	pluginSettings: PluginSettings; | ||||
| 	updatingPluginIds: Record<string, boolean>; | ||||
| 	updatablePluginIds: Record<string, boolean>; | ||||
| } | ||||
|  | ||||
| const useUpdateState = ({ pluginId, pluginSettings, updatablePluginIds, updatingPluginIds }: Props) => { | ||||
| 	return useMemo(() => { | ||||
| 		const settings = pluginSettings[pluginId]; | ||||
|  | ||||
| 		// Uninstalled | ||||
| 		if (!settings) return UpdateState.Idle; | ||||
|  | ||||
| 		if (settings.hasBeenUpdated) { | ||||
| 			return UpdateState.HasBeenUpdated; | ||||
| 		} | ||||
| 		if (updatingPluginIds[pluginId]) { | ||||
| 			return UpdateState.Updating; | ||||
| 		} | ||||
| 		if (updatablePluginIds[pluginId]) { | ||||
| 			return UpdateState.CanUpdate; | ||||
| 		} | ||||
| 		return UpdateState.Idle; | ||||
| 	}, [pluginSettings, updatingPluginIds, pluginId, updatablePluginIds]); | ||||
| }; | ||||
|  | ||||
| export default useUpdateState; | ||||
| @@ -9,3 +9,7 @@ export interface CustomSettingSection { | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| export type UpdateSettingValueCallback = (key: string, value: any)=> Promise<void>; | ||||
|  | ||||
| export interface PluginStatusRecord { | ||||
| 	[pluginId: string]: boolean; | ||||
| } | ||||
|   | ||||
| @@ -13,7 +13,7 @@ import { View, StyleSheet, AccessibilityInfo } from 'react-native'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import Setting from '@joplin/lib/models/Setting'; | ||||
| import { Dispatch } from 'redux'; | ||||
| import DismissibleDialog from '../../../components/DismissibleDialog'; | ||||
| import DismissibleDialog, { DialogSize } from '../../../components/DismissibleDialog'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| @@ -168,6 +168,7 @@ const PluginPanelViewer: React.FC<Props> = props => { | ||||
| 			<DismissibleDialog | ||||
| 				themeId={props.themeId} | ||||
| 				visible={props.visible} | ||||
| 				size={DialogSize.Large} | ||||
| 				onDismiss={onClose} | ||||
| 			> | ||||
| 				{renderTabContent()} | ||||
|   | ||||
| @@ -1,10 +1,42 @@ | ||||
| import PluginService from '@joplin/lib/services/plugins/PluginService'; | ||||
| import { useMemo } from 'react'; | ||||
| import Logger from '@joplin/utils/Logger'; | ||||
| import { useEffect, useMemo, useRef, useState } from 'react'; | ||||
|  | ||||
| const logger = Logger.create('usePlugin'); | ||||
|  | ||||
| const usePlugin = (pluginId: string) => { | ||||
| 	return useMemo(() => { | ||||
| 	const [pluginReloadCounter, setPluginReloadCounter] = useState(0); | ||||
|  | ||||
| 	const plugin = useMemo(() => { | ||||
| 		if (!PluginService.instance().pluginIds.includes(pluginId)) { | ||||
| 			return null; | ||||
| 		} | ||||
|  | ||||
| 		if (pluginReloadCounter > 0) { | ||||
| 			logger.debug('Reloading plugin', pluginId, 'because the set of loaded plugins changed.'); | ||||
| 		} | ||||
|  | ||||
| 		return PluginService.instance().pluginById(pluginId); | ||||
| 	}, [pluginId]); | ||||
| 		// The dependency on pluginReloadCounter is important -- it ensures that the plugin | ||||
| 		// matches the one loaded in the PluginService. | ||||
| 	}, [pluginId, pluginReloadCounter]); | ||||
|  | ||||
| 	const reloadCounterRef = useRef(0); | ||||
| 	reloadCounterRef.current = pluginReloadCounter; | ||||
|  | ||||
| 	// The plugin may need to be re-fetched from the PluginService. When a plugin is reloaded, | ||||
| 	// its Plugin object is replaced with a new one. | ||||
| 	useEffect(() => { | ||||
| 		const { remove } = PluginService.instance().addLoadedPluginsChangeListener(() => { | ||||
| 			setPluginReloadCounter(reloadCounterRef.current + 1); | ||||
| 		}); | ||||
|  | ||||
| 		return () => { | ||||
| 			remove(); | ||||
| 		}; | ||||
| 	}, []); | ||||
|  | ||||
| 	return plugin; | ||||
| }; | ||||
|  | ||||
| export default usePlugin; | ||||
|   | ||||
| @@ -1202,8 +1202,10 @@ class AppComponent extends React.Component { | ||||
| 					onPrimaryContainer: theme.color5, | ||||
| 					primaryContainer: theme.backgroundColor5, | ||||
|  | ||||
| 					primary: theme.color, | ||||
| 					onPrimary: theme.backgroundColor, | ||||
| 					outline: theme.codeBorderColor, | ||||
|  | ||||
| 					primary: theme.color4, | ||||
| 					onPrimary: theme.backgroundColor4, | ||||
|  | ||||
| 					background: theme.backgroundColor, | ||||
|  | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import { PluginManifest } from '../../../../services/plugins/utils/types'; | ||||
|  | ||||
| export interface PluginItem { | ||||
| 	manifest: PluginManifest; | ||||
| 	installed: boolean; | ||||
| 	enabled: boolean; | ||||
| 	deleted: boolean; | ||||
| 	devMode: boolean; | ||||
|   | ||||
| @@ -142,17 +142,20 @@ export default class PluginService extends BaseService { | ||||
| 		this.isSafeMode_ = v; | ||||
| 	} | ||||
|  | ||||
| 	public waitForLoadedPluginsChange() { | ||||
| 		return new Promise<void>(resolve => { | ||||
| 			this.pluginsChangeListeners_.push(() => resolve()); | ||||
| 		}); | ||||
| 	public addLoadedPluginsChangeListener(listener: ()=> void) { | ||||
| 		this.pluginsChangeListeners_.push(listener); | ||||
|  | ||||
| 		return { | ||||
| 			remove: () => { | ||||
| 				this.pluginsChangeListeners_ = this.pluginsChangeListeners_.filter(l => (l !== listener)); | ||||
| 			}, | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	private dispatchPluginsChangeListeners() { | ||||
| 		for (const listener of this.pluginsChangeListeners_) { | ||||
| 			listener(); | ||||
| 		} | ||||
| 		this.pluginsChangeListeners_ = []; | ||||
| 	} | ||||
|  | ||||
| 	private setPluginAt(pluginId: string, plugin: Plugin) { | ||||
|   | ||||
| @@ -45,6 +45,7 @@ const input: Theme = { | ||||
| 	searchMarkerColor: 'black', | ||||
|  | ||||
| 	warningBackgroundColor: '#FFD08D', | ||||
| 	destructiveColor: '#F00000', | ||||
|  | ||||
| 	tableBackgroundColor: 'rgb(247, 247, 247)', | ||||
| 	codeBackgroundColor: 'rgb(243, 243, 243)', | ||||
| @@ -89,6 +90,7 @@ const expected = ` | ||||
| 	--joplin-color-warn2: #ffcb81; | ||||
| 	--joplin-color-warn3: #ff7626; | ||||
| 	--joplin-color-warn-url: #155BDA; | ||||
| 	--joplin-destructive-color: #F00000; | ||||
| 	--joplin-divider-color: #dddddd; | ||||
| 	--joplin-header-background-color: #ffffff; | ||||
| 	--joplin-odd-background-color: #eeeeee; | ||||
|   | ||||
| @@ -48,6 +48,7 @@ const theme: Theme = { | ||||
| 	searchMarkerColor: 'black', | ||||
|  | ||||
| 	warningBackgroundColor: '#013F74', | ||||
| 	destructiveColor: '#F07777', | ||||
|  | ||||
| 	tableBackgroundColor: 'rgb(40, 41, 42)', | ||||
| 	codeBackgroundColor: 'rgb(47, 48, 49)', | ||||
|   | ||||
| @@ -45,6 +45,7 @@ const theme: Theme = { | ||||
| 	searchMarkerColor: 'black', | ||||
|  | ||||
| 	warningBackgroundColor: '#FFD08D', | ||||
| 	destructiveColor: '#D00707', | ||||
|  | ||||
| 	tableBackgroundColor: 'rgb(247, 247, 247)', | ||||
| 	codeBackgroundColor: 'rgb(243, 243, 243)', | ||||
|   | ||||
| @@ -50,6 +50,7 @@ export interface Theme { | ||||
| 	searchMarkerColor: string; | ||||
|  | ||||
| 	warningBackgroundColor: string; | ||||
| 	destructiveColor: string; | ||||
|  | ||||
| 	tableBackgroundColor: string; | ||||
| 	codeBackgroundColor: string; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user