1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-10-31 00:07:48 +02:00

Mobile: Support accepting Joplin Cloud shares (#10300)

This commit is contained in:
Henry Heino
2024-04-15 10:17:34 -07:00
committed by GitHub
parent 86d9f7e1cb
commit ff86c253d3
24 changed files with 668 additions and 52 deletions

View File

@@ -460,7 +460,6 @@ packages/app-desktop/services/plugins/hooks/useThemeCss.js
packages/app-desktop/services/plugins/hooks/useViewIsReady.js
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
packages/app-desktop/services/restart.js
packages/app-desktop/services/share/invitationRespond.js
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js
packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.js
@@ -601,6 +600,10 @@ packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes.js
packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
packages/app-mobile/components/screens/ShareManager/IncomingShareItem.js
packages/app-mobile/components/screens/ShareManager/index.test.js
packages/app-mobile/components/screens/ShareManager/index.js
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/search.js
@@ -648,6 +651,7 @@ packages/app-mobile/utils/ShareExtension.js
packages/app-mobile/utils/ShareUtils.test.js
packages/app-mobile/utils/ShareUtils.js
packages/app-mobile/utils/TlsUtils.js
packages/app-mobile/utils/appDefaultState.js
packages/app-mobile/utils/autodetectTheme.js
packages/app-mobile/utils/checkPermissions.js
packages/app-mobile/utils/createRootStyle.js
@@ -669,6 +673,7 @@ packages/app-mobile/utils/polyfills/index.js
packages/app-mobile/utils/setupNotifications.js
packages/app-mobile/utils/shareHandler.js
packages/app-mobile/utils/showMessageBox.js
packages/app-mobile/utils/testing/createMockReduxStore.js
packages/app-mobile/utils/types.js
packages/default-plugins/build.js
packages/default-plugins/buildDefaultPlugins.js
@@ -1097,6 +1102,7 @@ packages/lib/services/search/gotoAnythingStyleQuery.js
packages/lib/services/search/queryBuilder.js
packages/lib/services/share/ShareService.test.js
packages/lib/services/share/ShareService.js
packages/lib/services/share/invitationRespond.js
packages/lib/services/share/reducer.js
packages/lib/services/spellChecker/SpellCheckerService.js
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js
@@ -1148,6 +1154,8 @@ packages/lib/shim-init-node.js
packages/lib/shim.js
packages/lib/string-utils.test.js
packages/lib/string-utils.js
packages/lib/testing/share/makeMockShareInvitation.js
packages/lib/testing/share/mockShareService.js
packages/lib/testing/syncTargetUtils.js
packages/lib/testing/test-utils-synchronizer.js
packages/lib/testing/test-utils.js

10
.gitignore vendored
View File

@@ -440,7 +440,6 @@ packages/app-desktop/services/plugins/hooks/useThemeCss.js
packages/app-desktop/services/plugins/hooks/useViewIsReady.js
packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js
packages/app-desktop/services/restart.js
packages/app-desktop/services/share/invitationRespond.js
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.test.js
packages/app-desktop/services/sortOrder/PerFolderSortOrderService.js
packages/app-desktop/services/sortOrder/notesSortOrderUtils.test.js
@@ -581,6 +580,10 @@ packages/app-mobile/components/screens/LogScreen.js
packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/NoteTagsDialog.js
packages/app-mobile/components/screens/Notes.js
packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js
packages/app-mobile/components/screens/ShareManager/IncomingShareItem.js
packages/app-mobile/components/screens/ShareManager/index.test.js
packages/app-mobile/components/screens/ShareManager/index.js
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
packages/app-mobile/components/screens/encryption-config.js
packages/app-mobile/components/screens/search.js
@@ -628,6 +631,7 @@ packages/app-mobile/utils/ShareExtension.js
packages/app-mobile/utils/ShareUtils.test.js
packages/app-mobile/utils/ShareUtils.js
packages/app-mobile/utils/TlsUtils.js
packages/app-mobile/utils/appDefaultState.js
packages/app-mobile/utils/autodetectTheme.js
packages/app-mobile/utils/checkPermissions.js
packages/app-mobile/utils/createRootStyle.js
@@ -649,6 +653,7 @@ packages/app-mobile/utils/polyfills/index.js
packages/app-mobile/utils/setupNotifications.js
packages/app-mobile/utils/shareHandler.js
packages/app-mobile/utils/showMessageBox.js
packages/app-mobile/utils/testing/createMockReduxStore.js
packages/app-mobile/utils/types.js
packages/default-plugins/build.js
packages/default-plugins/buildDefaultPlugins.js
@@ -1077,6 +1082,7 @@ packages/lib/services/search/gotoAnythingStyleQuery.js
packages/lib/services/search/queryBuilder.js
packages/lib/services/share/ShareService.test.js
packages/lib/services/share/ShareService.js
packages/lib/services/share/invitationRespond.js
packages/lib/services/share/reducer.js
packages/lib/services/spellChecker/SpellCheckerService.js
packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.js
@@ -1128,6 +1134,8 @@ packages/lib/shim-init-node.js
packages/lib/shim.js
packages/lib/string-utils.test.js
packages/lib/string-utils.js
packages/lib/testing/share/makeMockShareInvitation.js
packages/lib/testing/share/mockShareService.js
packages/lib/testing/syncTargetUtils.js
packages/lib/testing/test-utils-synchronizer.js
packages/lib/testing/test-utils.js

View File

@@ -40,7 +40,7 @@ import ElectronAppWrapper from '../../ElectronAppWrapper';
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
import commands from './commands/index';
import invitationRespond from '../../services/share/invitationRespond';
import invitationRespond from '@joplin/lib/services/share/invitationRespond';
import restart from '../../services/restart';
const { connect } = require('react-redux');
import PromptDialog from '../PromptDialog';

View File

@@ -3,6 +3,9 @@ import { WarningBannerComponent } from './WarningBanner';
import Setting from '@joplin/lib/models/Setting';
import NavService from '@joplin/lib/services/NavService';
import { render, screen, userEvent } from '@testing-library/react-native';
import '@testing-library/jest-native/extend-expect';
import { ShareInvitation, ShareUserStatus } from '@joplin/lib/services/share/reducer';
import makeShareInvitation from '@joplin/lib/testing/share/makeMockShareInvitation';
interface WrapperProps {
showMissingMasterKeyMessage?: boolean;
@@ -11,6 +14,8 @@ interface WrapperProps {
showShouldUpgradeSyncTargetMessage?: boolean;
hasDisabledEncryptionItems?: boolean;
mustUpgradeAppMessage?: string;
shareInvitations?: ShareInvitation[];
processingShareInvitationResponse?: boolean;
}
const WarningBannerWrapper: React.FC<WrapperProps> = props => {
@@ -22,9 +27,12 @@ const WarningBannerWrapper: React.FC<WrapperProps> = props => {
showShouldUpgradeSyncTargetMessage={props.showShouldUpgradeSyncTargetMessage ?? false}
hasDisabledEncryptionItems={props.hasDisabledEncryptionItems ?? false}
mustUpgradeAppMessage={props.mustUpgradeAppMessage ?? ''}
shareInvitations={props.shareInvitations ?? []}
processingShareInvitationResponse={props.processingShareInvitationResponse ?? false}
/>;
};
describe('WarningBanner', () => {
let navServiceMock: jest.Mock<(route: unknown)=> void>;
beforeEach(() => {
@@ -45,4 +53,44 @@ describe('WarningBanner', () => {
expect(navServiceMock.mock.lastCall).toMatchObject([{ routeName: 'EncryptionConfig' }]);
});
test.each([
[makeShareInvitation('Test user', 'email@example.com', ShareUserStatus.Waiting), true],
[makeShareInvitation('Test user', 'email@example.com', ShareUserStatus.Accepted), false],
[makeShareInvitation('Test user', 'email@example.com', ShareUserStatus.Rejected), false],
])('should display a warning banner when there is an incoming share (case %#)', (invitation, shouldShow) => {
const invitations = [invitation];
render(<WarningBannerWrapper shareInvitations={invitations}/>);
const checkShownState = () => {
if (shouldShow) {
expect(screen.getByText(/would like to share a notebook/)).toBeVisible();
} else {
expect(screen.queryByText(/would like to share a notebook/)).toBeNull();
}
};
checkShownState();
// Should not be affected by additional rejected/accepted invitations
for (const inviteType of [ShareUserStatus.Accepted, ShareUserStatus.Rejected]) {
render(
<WarningBannerWrapper
shareInvitations={[...invitations, makeShareInvitation('A', 'a@example.com', inviteType)]}
/>,
);
checkShownState();
}
});
test('should not display a share warning banner while processing shares', () => {
const invitations = [makeShareInvitation('Test Name', 'email@example.com', ShareUserStatus.Waiting)];
const query = /Test Name \(email@example\.com\) would like to share a notebook/;
render(<WarningBannerWrapper shareInvitations={invitations} processingShareInvitationResponse={false}/>);
expect(screen.getByText(query)).toBeVisible();
render(<WarningBannerWrapper shareInvitations={invitations} processingShareInvitationResponse={true}/>);
expect(screen.queryByText(query)).toBeNull();
render(<WarningBannerWrapper shareInvitations={invitations} processingShareInvitationResponse={false}/>);
expect(screen.getByText(query)).toBeVisible();
});
});

View File

@@ -6,6 +6,8 @@ import { _ } from '@joplin/lib/locale';
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
import Setting from '@joplin/lib/models/Setting';
import { ShareInvitation, ShareUserStatus } from '@joplin/lib/services/share/reducer';
import { substrWithEllipsis } from '@joplin/lib/string-utils';
interface Props {
themeId: number;
@@ -15,6 +17,8 @@ interface Props {
showShouldUpgradeSyncTargetMessage: boolean|undefined;
hasDisabledEncryptionItems: boolean;
mustUpgradeAppMessage: string;
shareInvitations: ShareInvitation[];
processingShareInvitationResponse: boolean;
}
@@ -47,6 +51,22 @@ export const WarningBannerComponent: React.FC<Props> = props => {
warningComps.push(renderWarningBox('Status', _('Some items cannot be decrypted.')));
}
const shareInvitation = props.shareInvitations.find(inv => inv.status === ShareUserStatus.Waiting);
if (
!props.processingShareInvitationResponse
&& !!shareInvitation
) {
const invitation = props.shareInvitations.find(inv => inv.status === ShareUserStatus.Waiting);
const sharer = invitation.share.user;
warningComps.push(renderWarningBox(
'ShareManager',
_('%s (%s) would like to share a notebook with you.',
substrWithEllipsis(sharer.full_name, 0, 48),
substrWithEllipsis(sharer.email, 0, 52)),
));
}
return warningComps;
};
@@ -63,5 +83,7 @@ export default connect((state: AppState) => {
hasDisabledSyncItems: state.hasDisabledSyncItems,
shouldUpgradeSyncTarget: state.settings['sync.upgradeState'] === Setting.SYNC_UPGRADE_STATE_SHOULD_DO,
mustUpgradeAppMessage: state.mustUpgradeAppMessage,
shareInvitations: state.shareService.shareInvitations,
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
};
})(WarningBannerComponent);

View File

@@ -147,6 +147,10 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
void NavService.go('Log');
};
private manageSharesPress_ = () => {
void NavService.go('ShareManager');
};
private setShowSearch_(searching: boolean) {
if (searching !== this.state.searching) {
this.setState({ searching });
@@ -523,6 +527,10 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
addSettingButton('status_button', _('Sync Status'), this.syncStatusButtonPress_);
addSettingButton('log_button', _('Log'), this.logButtonPress_);
addSettingButton('fix_search_engine_index', this.state.fixingSearchIndex ? _('Fixing search index...') : _('Fix search index'), this.fixSearchEngineIndexButtonPress_, { disabled: this.state.fixingSearchIndex, description: _('Use this to rebuild the search index if there is a problem with search. It may take a long time depending on the number of notes.') });
const syncTargetInfo = SyncTargetRegistry.infoById(this.state.settings['sync.target']);
if (syncTargetInfo.supportsShare) {
addSettingButton('manage_shares_button', _('Manage shared folders'), this.manageSharesPress_);
}
}
if (section.name === 'importOrExport') {

View File

@@ -0,0 +1,95 @@
import * as React from 'react';
import { ShareInvitation } from '@joplin/lib/services/share/reducer';
import { Button, Card, Icon } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
import { useCallback, useState } from 'react';
import ShareService from '@joplin/lib/services/share/ShareService';
import shim from '@joplin/lib/shim';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import Folder from '@joplin/lib/models/Folder';
import { FolderEntity } from '@joplin/lib/services/database/types';
import Logger from '@joplin/utils/Logger';
import { ViewStyle } from 'react-native';
interface Props {
invitation: ShareInvitation;
processing: boolean;
containerStyle: ViewStyle;
}
const AcceptedIcon = (props: { size: number }) => <Icon {...props} source='account-multiple-check'/>;
const useFolderTitle = (folderId: string) => {
const [folderTitle, setFolderTitle] = useState(undefined);
useAsyncEffect(async event => {
let folder: FolderEntity|null = null;
// If the share was just accepted, the folder might not exist yet.
// In this case, check for the shared item multiple times.
while (!folder && !event.cancelled) {
folder = await Folder.load(folderId);
if (folder) {
setFolderTitle(folder.title);
break;
}
await new Promise<void>(resolve => {
shim.setTimeout(() => resolve(), 1000);
});
}
}, [folderId]);
return folderTitle ?? '...';
};
const logger = Logger.create('AcceptedShareItem');
const AcceptedShareItem: React.FC<Props> = props => {
const invitation = props.invitation;
const sharer = invitation.share.user;
const [leaving, setLeaving] = useState(false);
// The "leave share" button can be briefly visible after leaving a share.
// When this is the case, keep track of hasLeft to prevent clicking it.
const [hasLeft, setHasLeft] = useState(false);
const onLeaveShare = useCallback(async () => {
try {
setLeaving(true);
if (await shim.showConfirmationDialog(_('This will remove the notebook from your collection and you will no longer have access to its content. Do you wish to continue?'))) {
await ShareService.instance().leaveSharedFolder(invitation.share.folder_id, sharer.id);
setHasLeft(true);
}
} catch (error) {
logger.error('Failed to leave share', error);
await shim.showMessageBox(
_('Failed to leave share. Please verify that Joplin is connected to the internet and able to sync.\nError: %s', error),
{ buttons: [_('OK')] },
);
} finally {
setLeaving(false);
}
}, [invitation, sharer]);
const folderId = invitation.share.folder_id;
const folderTitle = useFolderTitle(folderId);
return <Card style={props.containerStyle}>
<Card.Title
left={AcceptedIcon}
title={_('Notebook: %s (%s)', folderTitle, folderId)}
subtitle={_('Share from %s (%s)', sharer.full_name, sharer.email)}
/>
<Card.Actions>
<Button
icon='share-off'
onPress={onLeaveShare}
disabled={props.processing || leaving || hasLeft}
loading={leaving || !folderTitle}
>{_('Leave share')}</Button>
</Card.Actions>
</Card>;
};
export default AcceptedShareItem;

View File

@@ -0,0 +1,50 @@
import * as React from 'react';
import { ShareInvitation } from '@joplin/lib/services/share/reducer';
import invitationRespond from '@joplin/lib/services/share/invitationRespond';
import { Button, Card, Icon, Text } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';
import { useCallback } from 'react';
import { ViewStyle } from 'react-native';
interface Props {
invitation: ShareInvitation;
processing: boolean;
containerStyle: ViewStyle;
}
const ShareIcon = (props: { size: number }) => <Icon {...props} source='account-arrow-left'/>;
const IncomingShareItem: React.FC<Props> = props => {
const invitation = props.invitation;
const onAcceptInvitation = useCallback(() => {
void invitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, true);
}, [invitation]);
const onRejectInvitation = useCallback(() => {
void invitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, false);
}, [invitation]);
const sharer = invitation.share.user;
if (!sharer) return <Text>Error: Share missing user</Text>; // Should not happen
return <Card style={props.containerStyle}>
<Card.Title
left={ShareIcon}
title={_('Share from %s (%s)', sharer.full_name, sharer.email)}
/>
<Card.Actions>
<Button
icon='check'
onPress={onAcceptInvitation}
disabled={props.processing}
>{_('Accept')}</Button>
<Button
icon='close'
onPress={onRejectInvitation}
disabled={props.processing}
>{_('Reject')}</Button>
</Card.Actions>
</Card>;
};
export default IncomingShareItem;

View File

@@ -0,0 +1,115 @@
import * as React from 'react';
import { ShareManagerComponent } from './index';
import Setting from '@joplin/lib/models/Setting';
import mockShareService from '@joplin/lib/testing/share/mockShareService';
import { fireEvent, render, screen, userEvent, waitFor } from '@testing-library/react-native';
import '@testing-library/jest-native/extend-expect';
import { ShareInvitation, ShareUserStatus } from '@joplin/lib/services/share/reducer';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import ShareService from '@joplin/lib/services/share/ShareService';
import makeMockShareInvitation from '@joplin/lib/testing/share/makeMockShareInvitation';
import { Provider } from 'react-redux';
import createMockReduxStore from '../../../utils/testing/createMockReduxStore';
import { AppState } from '../../../utils/types';
import { Store } from 'redux';
interface WrapperProps {
shareInvitations: ShareInvitation[];
store: Store<AppState>;
}
const ShareManagerWrapper: React.FC<WrapperProps> = props => {
return (
<Provider store={props.store}>
<ShareManagerComponent
themeId={Setting.THEME_LIGHT}
shareInvitations={props.shareInvitations}
processingShareInvitationResponse={false}
/>
</Provider>
);
};
describe('ShareManager', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
jest.useRealTimers();
});
test('should refresh incoming share invitations on pull', async () => {
const store = createMockReduxStore();
let shares: ShareInvitation[] = [makeMockShareInvitation('UserNameHere', 'usernamehere@example.com', ShareUserStatus.Waiting)];
const getShareInvitationsMock = jest.fn(async () => {
return {
items: shares,
};
});
mockShareService({
getShareInvitations: getShareInvitationsMock,
getShares: async () => ({ items: [] }),
postShares: async () => ({ id: 'test-id' }),
}, ShareService.instance(), store);
render(<ShareManagerWrapper shareInvitations={shares} store={store}/>);
expect(await screen.findByText('Share from UserNameHere (usernamehere@example.com)')).toBeVisible();
getShareInvitationsMock.mockClear();
shares = [
...shares,
makeMockShareInvitation('Username2', 'test@example.com', ShareUserStatus.Waiting),
];
// See https://github.com/callstack/react-native-testing-library/issues/809#issuecomment-984823700
const { refreshControl } = screen.getByTestId('refreshControl').props;
fireEvent(refreshControl, 'refresh');
// Should try to refresh shares
expect(getShareInvitationsMock).toHaveBeenCalled();
render(<ShareManagerWrapper shareInvitations={shares} store={store}/>);
// Should now list both
expect(await screen.findByText(/^Share from UserNameHere/)).toBeVisible();
expect(await screen.findByText(/^Share from Username2/)).toBeVisible();
});
test('should support accepting shares', async () => {
const store = createMockReduxStore();
let shares = [makeMockShareInvitation('UserNameHere', 'usernamehere@example.com', ShareUserStatus.Waiting)];
const onUpdateShareItems = jest.fn();
mockShareService({
async onExec(method, path, _query, body: Record<string, unknown>) {
if (method === 'GET' && path === 'api/share_users') {
return { items: shares };
}
if (method === 'PATCH' && path.startsWith('api/share_users/')) {
onUpdateShareItems(body.status as ShareUserStatus);
return null;
}
return null;
},
}, ShareService.instance(), store);
render(<ShareManagerWrapper shareInvitations={shares} store={store}/>);
const acceptButton = await screen.findByRole('button', { name: 'Accept' });
expect(acceptButton).toBeVisible();
// Use fake timers to silence a userEvents warning.
jest.useFakeTimers();
const user = userEvent.setup();
await user.press(acceptButton);
await waitFor(() => {
expect(onUpdateShareItems).toHaveBeenCalledWith(ShareUserStatus.Accepted);
});
shares = [makeMockShareInvitation('UserNameHere', 'usernamehere@example.com', ShareUserStatus.Accepted)];
render(<ShareManagerWrapper shareInvitations={shares} store={store}/>);
// Should now allow leaving
expect(await screen.findByRole('button', { name: 'Leave share' })).toBeVisible();
});
});

View File

@@ -0,0 +1,136 @@
import * as React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { View, StyleSheet, Text, ScrollView, RefreshControl } from 'react-native';
import { themeStyle } from '../../global-style';
import ScreenHeader from '../../ScreenHeader';
import { _ } from '@joplin/lib/locale';
import { ShareInvitation, ShareUserStatus } from '@joplin/lib/services/share/reducer';
import { AppState } from '../../../utils/types';
import { connect } from 'react-redux';
import IncomingShareItem from './IncomingShareItem';
import AcceptedShareItem from './AcceptedShareItem';
import ShareService from '@joplin/lib/services/share/ShareService';
import { ThemeStyle } from '../../global-style';
interface Props {
themeId: number;
shareInvitations: ShareInvitation[];
processingShareInvitationResponse: boolean;
}
const useStyles = (theme: ThemeStyle) => {
return useMemo(() => {
const margin = theme.margin;
return StyleSheet.create({
root: {
flex: 1,
backgroundColor: theme.backgroundColor,
},
header: {
...theme.headerStyle,
marginLeft: margin,
marginTop: margin,
marginRight: margin,
},
noSharesText: {
...theme.normalText,
margin,
},
shareListContainer: {
flex: 1,
flexDirection: 'column',
margin,
},
scrollingContainer: {
height: '100%',
},
shareListItem: {
maxWidth: 700,
marginBottom: 5,
},
});
}, [theme]);
};
export const ShareManagerComponent: React.FC<Props> = props => {
const theme = themeStyle(props.themeId);
const styles = useStyles(theme);
const [refreshing, setRefreshing] = useState(false);
const onRefresh = useCallback(async () => {
setRefreshing(true);
await ShareService.instance().refreshShareInvitations();
setRefreshing(false);
}, []);
const incomingShareComponents: React.ReactNode[] = [];
const acceptedShareComponents: React.ReactNode[] = [];
for (const share of props.shareInvitations) {
if (share.status === ShareUserStatus.Waiting) {
incomingShareComponents.push(
<IncomingShareItem
key={`incoming-share-${share.id}`}
invitation={share}
processing={props.processingShareInvitationResponse}
containerStyle={styles.shareListItem}
/>,
);
} else if (share.status === ShareUserStatus.Accepted) {
acceptedShareComponents.push(
<AcceptedShareItem
key={`accepted-share-${share.id}`}
invitation={share}
processing={props.processingShareInvitationResponse}
containerStyle={styles.shareListItem}
/>,
);
}
}
const renderNoIncomingShares = () => {
if (incomingShareComponents.length > 0) return null;
return <Text key='no-shares' style={styles.noSharesText}>{_('No incoming shares')}</Text>;
};
const renderAcceptedShares = () => {
if (acceptedShareComponents.length === 0) return null;
return <>
<Text style={styles.header}>{_('Accepted shares')}</Text>
<View style={styles.shareListContainer}>
{acceptedShareComponents}
</View>
</>;
};
return (
<View style={styles.root}>
<ScreenHeader title={_('Shares')} />
<ScrollView
style={styles.scrollingContainer}
refreshControl={
<RefreshControl
tintColor={theme.color}
colors={[theme.color]}
refreshing={refreshing}
onRefresh={onRefresh}
/>
}
testID='refreshControl'
>
<Text style={styles.header}>{_('Incoming shares')}</Text>
<View style={styles.shareListContainer}>
{renderNoIncomingShares()}
{incomingShareComponents}
</View>
{renderAcceptedShares()}
</ScrollView>
</View>
);
};
export default connect((state: AppState) => {
return {
shareInvitations: state.shareService.shareInvitations,
processingShareInvitationResponse: state.shareService.processingShareInvitationResponse,
};
})(ShareManagerComponent);

View File

@@ -111,7 +111,6 @@ import { setRSA } from '@joplin/lib/services/e2ee/ppk';
import RSA from './services/e2ee/RSA.react-native';
import { runIntegrationTests as runRsaIntegrationTests } from '@joplin/lib/services/e2ee/ppkTestUtils';
import { Theme, ThemeAppearance } from '@joplin/lib/themes/type';
import { AppState } from './utils/types';
import ProfileSwitcher from './components/ProfileSwitcher/ProfileSwitcher';
import ProfileEditor from './components/ProfileSwitcher/ProfileEditor';
import sensorInfo, { SensorInfo } from './components/biometrics/sensorInfo';
@@ -128,6 +127,8 @@ import KeymapService from '@joplin/lib/services/KeymapService';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import initializeCommandService from './utils/initializeCommandService';
import PlatformImplementation from './plugins/PlatformImplementation';
import ShareManager from './components/screens/ShareManager';
import appDefaultState, { DEFAULT_ROUTE } from './utils/appDefaultState';
type SideMenuPosition = 'left' | 'right';
@@ -254,21 +255,6 @@ function historyCanGoBackTo(route: any) {
return true;
}
const DEFAULT_ROUTE = {
type: 'NAV_GO',
routeName: 'Notes',
smartFilterId: 'c3176726992c11e9ac940492261af972',
};
const appDefaultState: AppState = { ...defaultState, sideMenuOpenPercent: 0,
route: DEFAULT_ROUTE,
noteSelectionEnabled: false,
noteSideMenuOptions: null,
isOnMobileData: false,
disableSideMenuGestures: false,
showPanelsDialog: false,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const appReducer = (state = appDefaultState, action: any) => {
let newState = state;
@@ -1122,6 +1108,7 @@ class AppComponent extends React.Component {
JoplinCloudLogin: { screen: JoplinCloudLoginScreen },
EncryptionConfig: { screen: EncryptionConfigScreen },
UpgradeSyncTarget: { screen: UpgradeSyncTargetScreen },
ShareManager: { screen: ShareManager },
ProfileSwitcher: { screen: ProfileSwitcher },
ProfileEditor: { screen: ProfileEditor },
Log: { screen: LogScreen },

View File

@@ -0,0 +1,22 @@
import { defaultState } from '@joplin/lib/reducer';
import { AppState } from './types';
export const DEFAULT_ROUTE = {
type: 'NAV_GO',
routeName: 'Notes',
smartFilterId: 'c3176726992c11e9ac940492261af972',
};
const appDefaultState: AppState = {
smartFilterId: undefined,
...defaultState,
sideMenuOpenPercent: 0,
route: DEFAULT_ROUTE,
noteSelectionEnabled: false,
noteSideMenuOptions: null,
isOnMobileData: false,
disableSideMenuGestures: false,
showPanelsDialog: false,
};
export default appDefaultState;

View File

@@ -0,0 +1,19 @@
import reducer from '@joplin/lib/reducer';
import { createStore } from 'redux';
import appDefaultState from '../appDefaultState';
import Setting from '@joplin/lib/models/Setting';
const defaultState = {
...appDefaultState,
// Mocking theme in the default state is necessary to prevent "Theme not set!" warnings.
settings: { theme: Setting.THEME_LIGHT },
};
const testReducer = (state = defaultState, action: unknown) => {
return reducer(state, action);
};
const createMockReduxStore = () => {
return createStore(testReducer);
};
export default createMockReduxStore;

View File

@@ -10,5 +10,4 @@ export interface AppState extends State {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
noteSideMenuOptions: any;
disableSideMenuGestures: boolean;
themeId: number;
}

View File

@@ -54,6 +54,10 @@ export default class BaseSyncTarget {
return false;
}
public static supportsShare(): boolean {
return false;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public option(name: string, defaultValue: any = null) {
return this.options_ && name in this.options_ ? this.options_[name] : defaultValue;

View File

@@ -29,7 +29,7 @@ enum ExecOptionsTarget {
File = 'file',
}
interface ExecOptions {
export interface ExecOptions {
responseFormat?: ExecOptionsResponseFormat;
target?: ExecOptionsTarget;
path?: string;

View File

@@ -45,6 +45,10 @@ export default class SyncTargetJoplinCloud extends BaseSyncTarget {
return false;
}
public static override supportsShare(): boolean {
return true;
}
public async isAuthenticated() {
try {
const fileApi = await this.fileApi();

View File

@@ -69,6 +69,10 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
return true;
}
public static override supportsShare(): boolean {
return true;
}
public async fileApi(): Promise<FileApi> {
return super.fileApi();
}

View File

@@ -7,6 +7,7 @@ export interface SyncTargetInfo {
supportsSelfHosted: boolean;
supportsConfigCheck: boolean;
supportsRecursiveLinkedNotes: boolean;
supportsShare: boolean;
description: string;
classRef: typeof BaseSyncTarget;
}
@@ -37,6 +38,7 @@ export default class SyncTargetRegistry {
supportsSelfHosted: SyncTargetClass.supportsSelfHosted(),
supportsConfigCheck: SyncTargetClass.supportsConfigCheck(),
supportsRecursiveLinkedNotes: SyncTargetClass.supportsRecursiveLinkedNotes(),
supportsShare: SyncTargetClass.supportsShare(),
};
return output;
}

View File

@@ -1,8 +1,6 @@
import Note from '../../models/Note';
import { createFolderTree, encryptionService, loadEncryptionMasterKey, msleep, resourceService, setupDatabaseAndSynchronizer, simulateReadOnlyShareEnv, supportDir, switchClient, synchronizerStart } from '../../testing/test-utils';
import ShareService from './ShareService';
import reducer, { defaultState } from '../../reducer';
import { createStore } from 'redux';
import { NoteEntity, ResourceEntity } from '../database/types';
import Folder from '../../models/Folder';
import { setEncryptionEnabled, setPpk } from '../synchronizer/syncInfoUtils';
@@ -19,6 +17,7 @@ import ResourceService from '../ResourceService';
import Setting from '../../models/Setting';
import { ModelType } from '../../BaseModel';
import { remoteNotesFoldersResources } from '../../testing/test-utils-synchronizer';
import mockShareService from '../../testing/share/mockShareService';
interface TestShareFolderServiceOptions {
master_key_id?: string;
@@ -26,31 +25,14 @@ interface TestShareFolderServiceOptions {
const testImagePath = `${supportDir}/photo.jpg`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const testReducer = (state: any = defaultState, action: any) => {
return reducer(state, action);
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
function mockService(api: any) {
const service = new ShareService();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const store = createStore(testReducer as any);
service.initialize(store, encryptionService(), api);
return service;
}
const mockServiceForNoteSharing = () => {
return mockService({
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
exec: (method: string, path = '', _query: Record<string, any> = null, _body: any = null, _headers: any = null, _options: any = null): Promise<any> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
if (method === 'GET' && path === 'api/shares') return { items: [] } as any;
return null;
},
personalizedUserContentBaseUrl(_userId: string) {
return mockShareService({
getShares: async () => {
return { items: [] };
},
postShares: async () => null,
getShareInvitations: async () => null,
});
};
@@ -134,9 +116,9 @@ describe('ShareService', () => {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
function testShareFolderService(extraExecHandlers: Record<string, Function> = {}, options: TestShareFolderServiceOptions = {}) {
return mockService({
return mockShareService({
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
exec: async (method: string, path: string, query: Record<string, any>, body: any) => {
onExec: async (method: string, path: string, query: Record<string, any>, body: any) => {
if (extraExecHandlers[`${method} ${path}`]) return extraExecHandlers[`${method} ${path}`](query, body);
if (method === 'GET' && path === 'api/shares') {

View File

@@ -1,9 +1,9 @@
import ShareService from '@joplin/lib/services/share/ShareService';
import ShareService from './ShareService';
import Logger from '@joplin/utils/Logger';
import Folder from '@joplin/lib/models/Folder';
import { reg } from '@joplin/lib/registry';
import { _ } from '@joplin/lib/locale';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
import Folder from '../../models/Folder';
import { reg } from '../../registry';
import { _ } from '../../locale';
import { MasterKeyEntity } from '../e2ee/types';
const logger = Logger.create('invitationRespond');

View File

@@ -0,0 +1,27 @@
import { ShareInvitation, ShareUserStatus } from '../../services/share/reducer';
let idCounter = 0;
const makeMockShareInvitation = (userName: string, userEmail: string, status: ShareUserStatus): ShareInvitation => {
const shareTypeFolder = 3;
return {
id: `test-${idCounter++}`,
master_key: null,
share: {
type: shareTypeFolder,
id: `share-id-${idCounter++}`,
folder_id: 'some-id-here',
user: {
id: `user-${idCounter++}`,
full_name: userName,
email: userEmail,
},
master_key_id: null,
note_id: null,
},
status: status,
can_read: 1,
can_write: 1,
};
};
export default makeMockShareInvitation;

View File

@@ -0,0 +1,74 @@
import { Store, createStore } from 'redux';
import reducer, { State, defaultState } from '../../reducer';
import ShareService from '../../services/share/ShareService';
import { encryptionService } from '../test-utils';
import JoplinServerApi, { ExecOptions } from '../../JoplinServerApi';
import { ShareInvitation, StateShare } from '../../services/share/reducer';
const testReducer = (state = defaultState, action: unknown) => {
return reducer(state, action);
};
type Query = Record<string, unknown>;
type OnShareGetListener = (query: Query)=> Promise<{ items: Partial<StateShare>[] }>;
type OnSharePostListener = (query: Query)=> Promise<{ id: string }>;
type OnInvitationGetListener = (query: Query)=> Promise<{ items: Partial<ShareInvitation>[] }>;
type OnApiExecListener = (
method: string,
path: string,
query: Query,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Needs to interface with old code from before the rule was applied
body: any,
headers: Record<string, unknown>,
options: ExecOptions
)=> Promise<unknown>;
export type ApiMock = {
getShares: OnShareGetListener;
postShares: OnSharePostListener;
getShareInvitations: OnInvitationGetListener;
onUnhandled?: OnApiExecListener;
onExec?: undefined;
}|{
onExec: OnApiExecListener;
onUnhandled?: undefined;
getShareInvitations?: undefined;
getShares?: undefined;
postShares?: undefined;
};
// Initializes a share service with mocks
const mockShareService = (apiCallHandler: ApiMock, service?: ShareService, store?: Store<State>) => {
service ??= new ShareService();
const api: Partial<JoplinServerApi> = {
exec: (method, path = '', query = null, body = null, headers = null, options = null) => {
if (apiCallHandler.onExec) {
return apiCallHandler.onExec(method, path, query, body, headers, options);
}
if (path === 'api/shares') {
if (method === 'GET') {
return apiCallHandler.getShares(query);
} else if (method === 'POST') {
return apiCallHandler.postShares(query);
}
} else if (method === 'GET' && path === 'api/share_users') {
return apiCallHandler.getShareInvitations(query);
}
if (apiCallHandler.onUnhandled) {
return apiCallHandler.onUnhandled(method, path, query, body, headers, options);
}
return null;
},
personalizedUserContentBaseUrl(_userId) {
return null;
},
};
store ??= createStore(testReducer);
service.initialize(store, encryptionService(), api as JoplinServerApi);
return service;
};
export default mockShareService;

View File

@@ -66,6 +66,7 @@ import initLib from '../initLib';
import OcrDriverTesseract from '../services/ocr/drivers/OcrDriverTesseract';
import OcrService from '../services/ocr/OcrService';
import { createWorker } from 'tesseract.js';
import { reg } from '../registry';
// Each suite has its own separate data and temp directory so that multiple
// suites can be run at the same time. suiteName is what is used to
@@ -379,6 +380,7 @@ async function setupDatabase(id: number = null, options: any = null) {
await clearSettingFile(id);
await loadKeychainServiceAndSettings(options.keychainEnabled ? KeychainServiceDriver : KeychainServiceDriverDummy);
reg.setDb(databases_[id]);
Setting.setValue('sync.target', syncTargetId());
}