1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Mobile: Implement plugin screen redesign (#10465)

This commit is contained in:
Henry Heino 2024-06-04 01:57:52 -07:00 committed by GitHub
parent 19f0b667b1
commit 06f42e8246
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 1460 additions and 633 deletions

View File

@ -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
View File

@ -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

View File

@ -38,6 +38,7 @@ interface Props {
function manifestToItem(manifest: PluginManifest): PluginItem {
return {
manifest: manifest,
installed: true,
enabled: true,
deleted: false,
devMode: false,

View File

@ -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,

View File

@ -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}>

View 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);

View 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);

View File

@ -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'}
<List.Accordion
title={toggleAdvancedLabel}
expanded={this.state.showAdvancedSettings}
onPress={() => this.setState({ showAdvancedSettings: !this.state.showAdvancedSettings })}
>
<Text>{toggleAdvancedLabel}</Text>
</Button>
{this.state.showAdvancedSettings ? advancedSettingComps : null}
</>
</List.Accordion>
);
};

View File

@ -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>
);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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 = (
<ActionButton
const installButton = <InstallButton
item={item}
onPress={props.onInstall}
disabled={props.installState !== InstallState.NotInstalled || !props.isCompatible}
loading={props.installState === InstallState.Installing}
title={installButtonTitle()}
/>
);
onInstall={props.onInstall}
installState={props.installState}
isCompatible={props.isCompatible}
/>;
const getUpdateButtonTitle = () => {
if (props.updateState === UpdateState.Updating) return _('Updating...');
if (props.updateState === UpdateState.HasBeenUpdated) return _('Updated');
return _('Update');
};
const aboutButton = <ActionButton type={ButtonType.Link} item={item} onPress={props.onAboutPress} title={_('About')}/>;
const updateButton = (
<ActionButton
item={item}
onPress={props.onUpdate}
disabled={props.updateState !== UpdateState.CanUpdate || !props.isCompatible}
loading={props.updateState === UpdateState.Updating}
title={getUpdateButtonTitle()}
/>
);
const onPress = useCallback(() => {
props.onShowPluginInfo?.({ item: props.item });
}, [props.onShowPluginInfo, props.item]);
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;
const styles = useStyles(props.isCompatible);
return (
<Chip
icon='alert'
mode='outlined'
onPress={() => props.onShowPluginLog({ item })}
<TouchableRipple
accessibilityRole='button'
accessible={true}
onPress={props.onShowPluginInfo ? onPress : null}
style={styles.cardContainer}
>
{_('Error')}
</Chip>
);
};
const renderRecommendedChip = () => {
if (!props.item.manifest._recommended || !props.isCompatible) {
return null;
}
return <Chip
icon='crown'
<Card
mode='outlined'
onPress={onRecommendedPress}
style={styles.card}
testID='plugin-card'
>
{_('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 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>
<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>
);
};

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -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 = (
const [searchQuery, setSearchQuery] = useState('');
const searchAccordion = (
<List.Accordion
title={_('Install new plugins')}
description={_('Browse and install community plugins.')}
id='search'
>
<SearchPlugins
pluginSettings={props.pluginSettings}
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()}
<List.AccordionGroup>
<List.Accordion
title={_('Installed plugins')}
description={installedAccordionDescription}
id='installed'
>
<View style={styles.installedPluginsContainer}>
{installedPluginCards}
{showSearch ? searchComponent : null}
</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>
);
};

View File

@ -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;

View File

@ -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
<View style={props.styles.getContainerStyle(false)}>
<TextButton
type={ButtonType.Primary}
onPress={onInstallFromFile}
style={buttonStyle}
disabled={showLoadingAnimation || !canInstallPluginsFromFile()}
loading={showLoadingAnimation}
>
{buttonLabel()}
</Button>
</TextButton>
</View>
);
};

View File

@ -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;
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={installPlugin}
onInstall={onInstall}
onAboutPress={openWebsiteForPlugin}
/>
);
}, [installPlugin, props.themeId]);
}
}, [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}

View File

@ -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>
);
};

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;
}

View File

@ -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()}

View File

@ -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;

View File

@ -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,

View File

@ -4,6 +4,7 @@ import { PluginManifest } from '../../../../services/plugins/utils/types';
export interface PluginItem {
manifest: PluginManifest;
installed: boolean;
enabled: boolean;
deleted: boolean;
devMode: boolean;

View File

@ -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) {

View File

@ -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;

View File

@ -48,6 +48,7 @@ const theme: Theme = {
searchMarkerColor: 'black',
warningBackgroundColor: '#013F74',
destructiveColor: '#F07777',
tableBackgroundColor: 'rgb(40, 41, 42)',
codeBackgroundColor: 'rgb(47, 48, 49)',

View File

@ -45,6 +45,7 @@ const theme: Theme = {
searchMarkerColor: 'black',
warningBackgroundColor: '#FFD08D',
destructiveColor: '#D00707',
tableBackgroundColor: 'rgb(247, 247, 247)',
codeBackgroundColor: 'rgb(243, 243, 243)',

View File

@ -50,6 +50,7 @@ export interface Theme {
searchMarkerColor: string;
warningBackgroundColor: string;
destructiveColor: string;
tableBackgroundColor: string;
codeBackgroundColor: string;