diff --git a/.eslintignore b/.eslintignore index 207f5fcc7..a3eb79a44 100644 --- a/.eslintignore +++ b/.eslintignore @@ -732,6 +732,9 @@ packages/app-desktop/services/plugins/hooks/useViewIsReady.js.map packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.d.ts packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js.map +packages/app-desktop/services/share/invitationRespond.d.ts +packages/app-desktop/services/share/invitationRespond.js +packages/app-desktop/services/share/invitationRespond.js.map packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.d.ts packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js.map diff --git a/.gitignore b/.gitignore index cd1b1d266..1f6625a43 100644 --- a/.gitignore +++ b/.gitignore @@ -715,6 +715,9 @@ packages/app-desktop/services/plugins/hooks/useViewIsReady.js.map packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.d.ts packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js packages/app-desktop/services/plugins/hooks/useWebviewToPluginMessages.js.map +packages/app-desktop/services/share/invitationRespond.d.ts +packages/app-desktop/services/share/invitationRespond.js +packages/app-desktop/services/share/invitationRespond.js.map packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.d.ts packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.js.map diff --git a/packages/app-desktop/gui/MainScreen/MainScreen.tsx b/packages/app-desktop/gui/MainScreen/MainScreen.tsx index e8c3a80bc..6a831f060 100644 --- a/packages/app-desktop/gui/MainScreen/MainScreen.tsx +++ b/packages/app-desktop/gui/MainScreen/MainScreen.tsx @@ -32,21 +32,17 @@ import removeItem from '../ResizableLayout/utils/removeItem'; import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService'; import ShareFolderDialog from '../ShareFolderDialog/ShareFolderDialog'; import { ShareInvitation } from '@joplin/lib/services/share/reducer'; -import ShareService from '@joplin/lib/services/share/ShareService'; -import { reg } from '@joplin/lib/registry'; import removeKeylessItems from '../ResizableLayout/utils/removeKeylessItems'; import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils'; import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils'; import commands from './commands/index'; -import Logger from '@joplin/lib/Logger'; +import invitationRespond from '../../services/share/invitationRespond'; const { connect } = require('react-redux'); const { PromptDialog } = require('../PromptDialog.min.js'); const NotePropertiesDialog = require('../NotePropertiesDialog.min.js'); const PluginManager = require('@joplin/lib/services/PluginManager'); const ipcRenderer = require('electron').ipcRenderer; -const logger = Logger.create('MainScreen'); - interface LayerModalState { visible: boolean; message: string; @@ -549,26 +545,8 @@ class MainScreenComponent extends React.Component { bridge().restart(); }; - const onInvitationRespond = async (shareUserId: string, accept: boolean) => { - // The below functions can take a bit of time to complete so in the - // meantime we hide the notification so that the user doesn't click - // multiple times on the Accept link. - ShareService.instance().setProcessingShareInvitationResponse(true); - - try { - await ShareService.instance().respondInvitation(shareUserId, accept); - } catch (error) { - logger.error(error); - alert(_('Could not respond to the invitation. Please try again, or check with the notebook owner if they are still sharing it.\n\nThe error was: "%s"', error.message)); - } - - try { - await ShareService.instance().refreshShareInvitations(); - } finally { - ShareService.instance().setProcessingShareInvitationResponse(false); - } - - void reg.scheduleSync(1000); + const onInvitationRespond = async (shareUserId: string, folderId: string, accept: boolean) => { + await invitationRespond(shareUserId, folderId, accept); }; let msg = null; @@ -613,9 +591,9 @@ class MainScreenComponent extends React.Component { msg = this.renderNotificationMessage( _('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email), _('Accept'), - () => onInvitationRespond(invitation.id, true), + () => onInvitationRespond(invitation.id, invitation.share.folder_id, true), _('Reject'), - () => onInvitationRespond(invitation.id, false) + () => onInvitationRespond(invitation.id, invitation.share.folder_id, false) ); } else if (this.props.hasDisabledSyncItems) { msg = this.renderNotificationMessage( diff --git a/packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.ts b/packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.ts index b06054a52..7664ec265 100644 --- a/packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.ts +++ b/packages/app-desktop/gui/MainScreen/commands/leaveSharedFolder.ts @@ -1,6 +1,6 @@ import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService'; import { _ } from '@joplin/lib/locale'; -import Folder from '@joplin/lib/models/Folder'; +import ShareService from '@joplin/lib/services/share/ShareService'; export const declaration: CommandDeclaration = { name: 'leaveSharedFolder', @@ -12,16 +12,7 @@ export const runtime = (): CommandRuntime => { execute: async (_context: CommandContext, folderId: string = null) => { const answer = confirm(_('This will remove the notebook from your collection and you will no longer have access to its content. Do you wish to continue?')); if (!answer) return; - - // In that case, we should only delete the folder but none of its - // children. Deleting the folder tells the server that we want to - // leave the share. The server will then proceed to delete all - // associated user_items. So eventually all the notebook content - // will also be deleted for the current user. - // - // We don't delete the children here because that would delete them - // for the other share participants too. - await Folder.delete(folderId, { deleteChildren: false }); + await ShareService.instance().leaveSharedFolder(folderId); }, enabledCondition: 'joplinServerConnected && folderIsShareRootAndNotOwnedByUser', }; diff --git a/packages/app-desktop/services/share/invitationRespond.ts b/packages/app-desktop/services/share/invitationRespond.ts new file mode 100644 index 000000000..980e87c20 --- /dev/null +++ b/packages/app-desktop/services/share/invitationRespond.ts @@ -0,0 +1,60 @@ +import ShareService from '@joplin/lib/services/share/ShareService'; +import Logger from '@joplin/lib/Logger'; +import Folder from '@joplin/lib/models/Folder'; +import { reg } from '@joplin/lib/registry'; +import { _ } from '@joplin/lib/locale'; + +const logger = Logger.create('invitationRespond'); + +export default async function(shareUserId: string, folderId: string, accept: boolean) { + // The below functions can take a bit of time to complete so in the + // meantime we hide the notification so that the user doesn't click + // multiple times on the Accept link. + ShareService.instance().setProcessingShareInvitationResponse(true); + + try { + await ShareService.instance().respondInvitation(shareUserId, accept); + } catch (error) { + logger.error(error); + alert(_('Could not respond to the invitation. Please try again, or check with the notebook owner if they are still sharing it.\n\nThe error was: "%s"', error.message)); + } + + // This is to handle an edge case that can happen if: + // + // - The user is a recipient of a share. + // - The sender removes the recipient from the share, then add him again. + // - The recipient gets the invitation, but reply "Reject" to it. + // + // If we don't handle this case, it would kind of work but would create + // conflicts because the shared notes would be converted to local ones, then + // during sync the synchronizer would try to delete them. Since they've been + // changed, they'll all be marked as conflicts. + // + // So the simplest thing to do is to leave the folder, which is most likely + // what the user wants. And if not, it's always possible to ask the sender + // to share again. + // + // NOTE: DOESN'T WORK. Because Folder.updateAllShareIds() would still run + // and change the notes share_id property, thus creating conflicts again. + // Leaving it as it is for now, as it's an unlikely scenario and it won't + // cause any data loss. + + console.info('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', shareUserId, folderId); + console.info('ALL', await Folder.all()); + + if (!accept) { + const existingFolder = await Folder.load(folderId); + if (existingFolder) { + logger.warn('Rejected an invitation, but the folder was already there. Conflicts are likely to happen. ShareUserId:', shareUserId, 'Folder ID:', folderId); + // await ShareService.instance().leaveSharedFolder(folderId); + } + } + + try { + await ShareService.instance().refreshShareInvitations(); + } finally { + ShareService.instance().setProcessingShareInvitationResponse(false); + } + + void reg.scheduleSync(1000); +} diff --git a/packages/lib/services/share/ShareService.ts b/packages/lib/services/share/ShareService.ts index 97aa530b4..6dfc57e8a 100644 --- a/packages/lib/services/share/ShareService.ts +++ b/packages/lib/services/share/ShareService.ts @@ -117,6 +117,20 @@ export default class ShareService { await Folder.updateAllShareIds(); } + // This is when a share recipient decides to leave the shared folder. + // + // In that case, we should only delete the folder but none of its children. + // Deleting the folder tells the server that we want to leave the share. The + // server will then proceed to delete all associated user_items. So + // eventually all the notebook content will also be deleted for the current + // user. + // + // We don't delete the children here because that would delete them for the + // other share participants too. + public async leaveSharedFolder(folderId: string): Promise { + await Folder.delete(folderId, { deleteChildren: false }); + } + public async shareNote(noteId: string): Promise { const note = await Note.load(noteId); if (!note) throw new Error(`No such note: ${noteId}`);