2021-05-13 18:57:37 +02:00
|
|
|
import Dialog from '../Dialog';
|
|
|
|
import DialogButtonRow, { ClickEvent, ButtonSpec } from '../DialogButtonRow';
|
|
|
|
import DialogTitle from '../DialogTitle';
|
|
|
|
import { _ } from '@joplin/lib/locale';
|
2023-07-16 18:42:42 +02:00
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
2021-05-13 18:57:37 +02:00
|
|
|
import { FolderEntity } from '@joplin/lib/services/database/types';
|
|
|
|
import Folder from '@joplin/lib/models/Folder';
|
2021-11-21 16:35:01 +02:00
|
|
|
import ShareService, { ApiShare } from '@joplin/lib/services/share/ShareService';
|
2021-05-13 18:57:37 +02:00
|
|
|
import styled from 'styled-components';
|
|
|
|
import StyledFormLabel from '../style/StyledFormLabel';
|
|
|
|
import StyledInput from '../style/StyledInput';
|
2021-10-15 17:41:38 +02:00
|
|
|
import Button, { ButtonSize } from '../Button/Button';
|
2021-05-13 18:57:37 +02:00
|
|
|
import Logger from '@joplin/lib/Logger';
|
|
|
|
import StyledMessage from '../style/StyledMessage';
|
2023-07-16 18:42:42 +02:00
|
|
|
import { SharePermissions, ShareUserStatus, StateShare, StateShareUser } from '@joplin/lib/services/share/reducer';
|
2021-05-13 18:57:37 +02:00
|
|
|
import { State } from '@joplin/lib/reducer';
|
|
|
|
import { connect } from 'react-redux';
|
|
|
|
import { reg } from '@joplin/lib/registry';
|
2021-09-06 17:57:07 +02:00
|
|
|
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
2023-07-16 18:42:42 +02:00
|
|
|
import { ChangeEvent, Dropdown, DropdownOptions, DropdownVariant } from '../Dropdown/Dropdown';
|
2021-05-13 18:57:37 +02:00
|
|
|
|
|
|
|
const logger = Logger.create('ShareFolderDialog');
|
|
|
|
|
2021-07-25 14:15:56 +02:00
|
|
|
const StyledRoot = styled.div`
|
|
|
|
min-width: 500px;
|
|
|
|
`;
|
|
|
|
|
2021-05-13 18:57:37 +02:00
|
|
|
const StyledFolder = styled.div`
|
|
|
|
border: 1px solid ${(props) => props.theme.dividerColor};
|
|
|
|
padding: 0.5em;
|
|
|
|
margin-bottom: 1em;
|
|
|
|
display: flex;
|
|
|
|
align-items: center;
|
|
|
|
`;
|
|
|
|
|
|
|
|
const StyledRecipientControls = styled.div`
|
|
|
|
display: flex;
|
|
|
|
flex-direction: row;
|
|
|
|
`;
|
|
|
|
|
|
|
|
const StyledRecipientInput = styled(StyledInput)`
|
|
|
|
width: 100%;
|
|
|
|
margin-right: 10px;
|
|
|
|
`;
|
|
|
|
|
|
|
|
const StyledAddRecipient = styled.div`
|
|
|
|
margin-bottom: 1em;
|
|
|
|
`;
|
|
|
|
|
2021-12-20 17:08:43 +02:00
|
|
|
const StyledRecipient = styled(StyledMessage)<any>`
|
2021-05-13 18:57:37 +02:00
|
|
|
display: flex;
|
|
|
|
flex-direction: row;
|
|
|
|
padding: .6em 1em;
|
|
|
|
background-color: ${props => props.index % 2 === 0 ? props.theme.backgroundColor : props.theme.oddBackgroundColor};
|
|
|
|
align-items: center;
|
|
|
|
`;
|
|
|
|
|
|
|
|
const StyledRecipientName = styled.div`
|
|
|
|
display: flex;
|
|
|
|
flex: 1;
|
|
|
|
`;
|
|
|
|
|
|
|
|
const StyledRecipientStatusIcon = styled.i`
|
|
|
|
margin-right: .6em;
|
|
|
|
`;
|
|
|
|
|
|
|
|
const StyledRecipients = styled.div`
|
2021-10-15 17:34:09 +02:00
|
|
|
margin-bottom: 10px;
|
2021-05-13 18:57:37 +02:00
|
|
|
`;
|
|
|
|
|
|
|
|
const StyledRecipientList = styled.div`
|
|
|
|
border: 1px solid ${(props: any) => props.theme.dividerColor};
|
|
|
|
border-radius: 3px;
|
|
|
|
height: 300px;
|
|
|
|
overflow-x: hidden;
|
|
|
|
overflow-y: scroll;
|
|
|
|
`;
|
|
|
|
|
|
|
|
const StyledError = styled(StyledMessage)`
|
|
|
|
word-break: break-all;
|
|
|
|
margin-bottom: 1em;
|
|
|
|
`;
|
|
|
|
|
2021-12-20 17:08:43 +02:00
|
|
|
const StyledShareState = styled(StyledMessage)<any>`
|
2021-05-13 18:57:37 +02:00
|
|
|
word-break: break-all;
|
|
|
|
margin-bottom: 1em;
|
|
|
|
`;
|
|
|
|
|
|
|
|
const StyledIcon = styled.i`
|
|
|
|
margin-right: 8px;
|
|
|
|
`;
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
themeId: number;
|
|
|
|
folderId: string;
|
|
|
|
onClose(): void;
|
|
|
|
shares: StateShare[];
|
|
|
|
shareUsers: Record<string, StateShareUser[]>;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface RecipientDeleteEvent {
|
|
|
|
shareUserId: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
enum ShareState {
|
|
|
|
Idle = 0,
|
|
|
|
Synchronizing = 1,
|
|
|
|
Creating = 2,
|
|
|
|
}
|
|
|
|
|
|
|
|
function ShareFolderDialog(props: Props) {
|
2023-07-16 18:42:42 +02:00
|
|
|
const permissionOptions: DropdownOptions = {
|
|
|
|
'can_read': _('Can view'),
|
|
|
|
'can_read_and_write': _('Can edit'),
|
|
|
|
};
|
|
|
|
|
2021-05-13 18:57:37 +02:00
|
|
|
const [folder, setFolder] = useState<FolderEntity>(null);
|
|
|
|
const [recipientEmail, setRecipientEmail] = useState<string>('');
|
2023-07-16 18:42:42 +02:00
|
|
|
const [recipientPermissions, setRecipientPermissions] = useState<string>('can_read');
|
2021-05-13 18:57:37 +02:00
|
|
|
const [latestError, setLatestError] = useState<Error>(null);
|
|
|
|
const [share, setShare] = useState<StateShare>(null);
|
|
|
|
const [shareUsers, setShareUsers] = useState<StateShareUser[]>([]);
|
|
|
|
const [shareState, setShareState] = useState<ShareState>(ShareState.Idle);
|
|
|
|
const [customButtons, setCustomButtons] = useState<ButtonSpec[]>([]);
|
2023-07-16 18:42:42 +02:00
|
|
|
const [recipientsBeingUpdated, setRecipientsBeingUpdated] = useState<Record<string, boolean>>({});
|
2021-05-13 18:57:37 +02:00
|
|
|
|
|
|
|
async function synchronize(event: AsyncEffectEvent = null) {
|
|
|
|
setShareState(ShareState.Synchronizing);
|
|
|
|
await reg.waitForSyncFinishedThenSync();
|
|
|
|
if (event && event.cancelled) return;
|
|
|
|
setShareState(ShareState.Idle);
|
|
|
|
}
|
|
|
|
|
|
|
|
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
|
|
|
const f = await Folder.load(props.folderId);
|
|
|
|
if (event.cancelled) return;
|
|
|
|
setFolder(f);
|
|
|
|
}, [props.folderId]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
void ShareService.instance().refreshShares();
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
|
|
|
await synchronize(event);
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
const s = props.shares.find(s => s.folder_id === props.folderId);
|
|
|
|
setShare(s);
|
2022-08-19 13:10:04 +02:00
|
|
|
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
|
2021-05-13 18:57:37 +02:00
|
|
|
}, [props.shares]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (!share) return;
|
|
|
|
void ShareService.instance().refreshShareUsers(share.id);
|
|
|
|
}, [share]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
setCustomButtons(share ? [{
|
|
|
|
name: 'unshare',
|
|
|
|
label: _('Unshare'),
|
|
|
|
}] : []);
|
|
|
|
}, [share]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
if (!share) return;
|
|
|
|
const sus = props.shareUsers[share.id];
|
|
|
|
if (!sus) return;
|
|
|
|
setShareUsers(sus);
|
|
|
|
}, [share, props.shareUsers]);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
void ShareService.instance().refreshShares();
|
|
|
|
}, [props.folderId]);
|
|
|
|
|
2023-07-16 18:42:42 +02:00
|
|
|
const permissionsFromString = (p: string): SharePermissions => {
|
|
|
|
return {
|
|
|
|
can_read: 1,
|
|
|
|
can_write: p === 'can_read_and_write' ? 1 : 0,
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
const shareRecipient_click = useCallback(async () => {
|
2021-05-13 18:57:37 +02:00
|
|
|
setShareState(ShareState.Creating);
|
2021-11-21 16:35:01 +02:00
|
|
|
setLatestError(null);
|
|
|
|
|
|
|
|
let errorSet = false;
|
|
|
|
|
|
|
|
const handleError = (error: any) => {
|
|
|
|
if (!errorSet) setLatestError(error);
|
|
|
|
errorSet = true;
|
|
|
|
logger.error(error);
|
|
|
|
};
|
|
|
|
|
|
|
|
const defer = (error: any) => {
|
|
|
|
if (error) handleError(error);
|
|
|
|
setShareState(ShareState.Idle);
|
|
|
|
};
|
|
|
|
|
|
|
|
let share: ApiShare = null;
|
|
|
|
|
|
|
|
try {
|
|
|
|
share = await ShareService.instance().shareFolder(props.folderId);
|
|
|
|
} catch (error) {
|
|
|
|
return defer(error);
|
|
|
|
}
|
2021-05-13 18:57:37 +02:00
|
|
|
|
|
|
|
try {
|
2023-07-16 18:42:42 +02:00
|
|
|
await ShareService.instance().addShareRecipient(share.id, share.master_key_id, recipientEmail, permissionsFromString(recipientPermissions));
|
2021-11-21 16:35:01 +02:00
|
|
|
} catch (error) {
|
|
|
|
// Handle the error but continue the process because we need to at
|
|
|
|
// least refresh the shares since one has been created above.
|
|
|
|
handleError(error);
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
2021-05-13 18:57:37 +02:00
|
|
|
await Promise.all([
|
|
|
|
ShareService.instance().refreshShares(),
|
|
|
|
ShareService.instance().refreshShareUsers(share.id),
|
|
|
|
]);
|
|
|
|
setRecipientEmail('');
|
|
|
|
|
|
|
|
await synchronize();
|
|
|
|
} catch (error) {
|
2021-11-21 16:35:01 +02:00
|
|
|
handleError(error);
|
2021-05-13 18:57:37 +02:00
|
|
|
} finally {
|
2021-11-21 16:35:01 +02:00
|
|
|
defer(null);
|
2021-05-13 18:57:37 +02:00
|
|
|
}
|
2023-07-16 18:42:42 +02:00
|
|
|
}, [recipientPermissions, props.folderId, recipientEmail]);
|
2021-05-13 18:57:37 +02:00
|
|
|
|
|
|
|
function recipientEmail_change(event: any) {
|
|
|
|
setRecipientEmail(event.target.value);
|
|
|
|
}
|
|
|
|
|
|
|
|
async function recipient_delete(event: RecipientDeleteEvent) {
|
|
|
|
if (!confirm(_('Delete this invitation? The recipient will no longer have access to this shared notebook.'))) return;
|
|
|
|
|
2021-10-13 20:19:29 +02:00
|
|
|
try {
|
|
|
|
await ShareService.instance().deleteShareRecipient(event.shareUserId);
|
|
|
|
} catch (error) {
|
|
|
|
logger.error(error);
|
|
|
|
alert(_('The recipient could not be removed from the list. Please try again.\n\nThe error was: "%s"', error.message));
|
|
|
|
}
|
|
|
|
|
2021-05-13 18:57:37 +02:00
|
|
|
await ShareService.instance().refreshShareUsers(share.id);
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderFolder() {
|
|
|
|
return (
|
|
|
|
<StyledFolder>
|
|
|
|
<StyledIcon className="icon-notebooks"/>{folder ? folder.title : '...'}
|
|
|
|
</StyledFolder>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-07-16 18:42:42 +02:00
|
|
|
const recipientPermissions_change = useCallback((event: ChangeEvent) => {
|
|
|
|
setRecipientPermissions(event.value);
|
|
|
|
}, []);
|
|
|
|
|
2021-05-13 18:57:37 +02:00
|
|
|
function renderAddRecipient() {
|
|
|
|
const disabled = shareState !== ShareState.Idle;
|
2023-07-16 18:42:42 +02:00
|
|
|
|
2021-05-13 18:57:37 +02:00
|
|
|
return (
|
|
|
|
<StyledAddRecipient>
|
|
|
|
<StyledFormLabel>{_('Add recipient:')}</StyledFormLabel>
|
|
|
|
<StyledRecipientControls>
|
|
|
|
<StyledRecipientInput disabled={disabled} type="email" placeholder="example@domain.com" value={recipientEmail} onChange={recipientEmail_change} />
|
2023-07-16 18:42:42 +02:00
|
|
|
<Dropdown className="permission-dropdown" options={permissionOptions} value={recipientPermissions} onChange={recipientPermissions_change}/>
|
2021-10-15 17:41:38 +02:00
|
|
|
<Button size={ButtonSize.Small} disabled={disabled} title={_('Share')} onClick={shareRecipient_click}></Button>
|
2021-05-13 18:57:37 +02:00
|
|
|
</StyledRecipientControls>
|
|
|
|
</StyledAddRecipient>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2023-07-16 18:42:42 +02:00
|
|
|
const recipient_permissionChange = useCallback(async (shareUserId: string, value: string) => {
|
|
|
|
try {
|
|
|
|
setRecipientsBeingUpdated(prev => {
|
|
|
|
return { ...prev, [shareUserId]: true };
|
|
|
|
});
|
|
|
|
await ShareService.instance().setPermissions(share.id, shareUserId, permissionsFromString(value));
|
|
|
|
} catch (error) {
|
|
|
|
alert(`Could not set permissions: ${error.message}`);
|
|
|
|
logger.error(error);
|
|
|
|
} finally {
|
|
|
|
setRecipientsBeingUpdated(prev => {
|
|
|
|
return { ...prev, [shareUserId]: false };
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}, [share]);
|
|
|
|
|
2021-05-13 18:57:37 +02:00
|
|
|
function renderRecipient(index: number, shareUser: StateShareUser) {
|
|
|
|
const statusToIcon = {
|
|
|
|
[ShareUserStatus.Waiting]: 'fas fa-question',
|
|
|
|
[ShareUserStatus.Rejected]: 'fas fa-times',
|
|
|
|
[ShareUserStatus.Accepted]: 'fas fa-check',
|
|
|
|
};
|
|
|
|
|
|
|
|
const statusToMessage = {
|
|
|
|
[ShareUserStatus.Waiting]: _('Recipient has not yet accepted the invitation'),
|
|
|
|
[ShareUserStatus.Rejected]: _('Recipient has rejected the invitation'),
|
|
|
|
[ShareUserStatus.Accepted]: _('Recipient has accepted the invitation'),
|
|
|
|
};
|
|
|
|
|
2023-07-16 18:42:42 +02:00
|
|
|
const permission = shareUser.can_write ? 'can_read_and_write' : 'can_read';
|
|
|
|
const enabled = !recipientsBeingUpdated[shareUser.id];
|
|
|
|
|
2021-05-13 18:57:37 +02:00
|
|
|
return (
|
|
|
|
<StyledRecipient key={shareUser.user.email} index={index}>
|
|
|
|
<StyledRecipientName>{shareUser.user.email}</StyledRecipientName>
|
2023-07-16 18:42:42 +02:00
|
|
|
<Dropdown disabled={!enabled} className="permission-dropdown" value={permission} options={permissionOptions} variant={DropdownVariant.NoBorder} onChange={event => recipient_permissionChange(shareUser.id, event.value)}/>
|
2021-05-13 18:57:37 +02:00
|
|
|
<StyledRecipientStatusIcon title={statusToMessage[shareUser.status]} className={statusToIcon[shareUser.status]}></StyledRecipientStatusIcon>
|
2023-07-16 18:42:42 +02:00
|
|
|
<Button disabled={!enabled} size={ButtonSize.Small} iconName="far fa-times-circle" onClick={() => recipient_delete({ shareUserId: shareUser.id })}/>
|
2021-05-13 18:57:37 +02:00
|
|
|
</StyledRecipient>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderRecipients() {
|
|
|
|
const listItems = shareUsers.map((su: StateShareUser, index: number) => renderRecipient(index, su));
|
|
|
|
|
|
|
|
return (
|
|
|
|
<StyledRecipients>
|
|
|
|
<StyledFormLabel>{_('Recipients:')}</StyledFormLabel>
|
|
|
|
<StyledRecipientList>
|
|
|
|
{listItems}
|
|
|
|
</StyledRecipientList>
|
|
|
|
</StyledRecipients>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderError() {
|
|
|
|
if (!latestError) return null;
|
|
|
|
|
|
|
|
return (
|
|
|
|
<StyledError type="error">
|
|
|
|
{latestError.message}
|
|
|
|
</StyledError>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderShareState() {
|
|
|
|
if (shareState === ShareState.Idle) return null;
|
|
|
|
|
|
|
|
const messages = {
|
|
|
|
[ShareState.Synchronizing]: _('Synchronizing...'),
|
|
|
|
[ShareState.Creating]: _('Sharing notebook...'),
|
|
|
|
};
|
|
|
|
|
|
|
|
const message = messages[shareState];
|
|
|
|
if (!message) throw new Error(`Unsupported state: ${shareState}`);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<StyledShareState>
|
|
|
|
{message}
|
|
|
|
</StyledShareState>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-10-15 17:34:09 +02:00
|
|
|
const renderInfo = () => {
|
|
|
|
return (
|
|
|
|
<p className="info-text -small">
|
|
|
|
{_('Please note that if it is a large notebook, it may take a few minutes for all the notes to show up on the recipient\'s device.')}
|
|
|
|
</p>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
2021-05-13 18:57:37 +02:00
|
|
|
async function buttonRow_click(event: ClickEvent) {
|
|
|
|
if (event.buttonName === 'unshare') {
|
|
|
|
if (!confirm(_('Unshare this notebook? The recipients will no longer have access to its content.'))) return;
|
|
|
|
await ShareService.instance().unshareFolder(props.folderId);
|
|
|
|
void synchronize();
|
|
|
|
}
|
|
|
|
|
|
|
|
props.onClose();
|
|
|
|
}
|
|
|
|
|
|
|
|
function renderContent() {
|
|
|
|
return (
|
2023-07-16 18:42:42 +02:00
|
|
|
<StyledRoot className="share-folder-dialog">
|
2021-05-13 18:57:37 +02:00
|
|
|
<DialogTitle title={_('Share Notebook')}/>
|
|
|
|
{renderFolder()}
|
|
|
|
{renderAddRecipient()}
|
|
|
|
{renderShareState()}
|
|
|
|
{renderError()}
|
|
|
|
{renderRecipients()}
|
2021-10-15 17:34:09 +02:00
|
|
|
{renderInfo()}
|
2021-05-13 18:57:37 +02:00
|
|
|
<DialogButtonRow
|
|
|
|
themeId={props.themeId}
|
|
|
|
onClick={buttonRow_click}
|
|
|
|
okButtonShow={false}
|
|
|
|
cancelButtonLabel={_('Close')}
|
|
|
|
customButtons={customButtons}
|
|
|
|
/>
|
2021-07-25 14:15:56 +02:00
|
|
|
</StyledRoot>
|
2021-05-13 18:57:37 +02:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<Dialog renderContent={renderContent}/>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
const mapStateToProps = (state: State) => {
|
|
|
|
return {
|
|
|
|
shares: state.shareService.shares,
|
|
|
|
shareUsers: state.shareService.shareUsers,
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
2023-06-30 11:22:47 +02:00
|
|
|
export default connect(mapStateToProps)(ShareFolderDialog);
|