You've already forked joplin
							
							
				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:
		| @@ -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
									
									
								
							
							
						
						
									
										10
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -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 | ||||
|   | ||||
| @@ -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'; | ||||
|   | ||||
| @@ -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(); | ||||
| 	}); | ||||
| }); | ||||
|   | ||||
| @@ -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); | ||||
|   | ||||
| @@ -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') { | ||||
|   | ||||
| @@ -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; | ||||
| @@ -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; | ||||
| @@ -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(); | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										136
									
								
								packages/app-mobile/components/screens/ShareManager/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										136
									
								
								packages/app-mobile/components/screens/ShareManager/index.tsx
									
									
									
									
									
										Normal 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); | ||||
| @@ -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 }, | ||||
|   | ||||
							
								
								
									
										22
									
								
								packages/app-mobile/utils/appDefaultState.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								packages/app-mobile/utils/appDefaultState.ts
									
									
									
									
									
										Normal 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; | ||||
							
								
								
									
										19
									
								
								packages/app-mobile/utils/testing/createMockReduxStore.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								packages/app-mobile/utils/testing/createMockReduxStore.ts
									
									
									
									
									
										Normal 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; | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -29,7 +29,7 @@ enum ExecOptionsTarget { | ||||
| 	File = 'file', | ||||
| } | ||||
|  | ||||
| interface ExecOptions { | ||||
| export interface ExecOptions { | ||||
| 	responseFormat?: ExecOptionsResponseFormat; | ||||
| 	target?: ExecOptionsTarget; | ||||
| 	path?: string; | ||||
|   | ||||
| @@ -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(); | ||||
|   | ||||
| @@ -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(); | ||||
| 	} | ||||
|   | ||||
| @@ -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; | ||||
| 			} | ||||
|   | ||||
| @@ -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') { | ||||
|   | ||||
| @@ -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'); | ||||
| 
 | ||||
							
								
								
									
										27
									
								
								packages/lib/testing/share/makeMockShareInvitation.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								packages/lib/testing/share/makeMockShareInvitation.ts
									
									
									
									
									
										Normal 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; | ||||
							
								
								
									
										74
									
								
								packages/lib/testing/share/mockShareService.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								packages/lib/testing/share/mockShareService.ts
									
									
									
									
									
										Normal 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; | ||||
| @@ -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()); | ||||
| } | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user