You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-10 22:11:50 +02:00
Mobile: Joplin Cloud/Server: Support publishing notes (#12350)
This commit is contained in:
@@ -822,6 +822,8 @@ 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/ShareNoteDialog.test.js
|
||||
packages/app-mobile/components/screens/ShareNoteDialog.js
|
||||
packages/app-mobile/components/screens/SsoLoginScreen.js
|
||||
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
|
||||
packages/app-mobile/components/screens/dropbox-login.js
|
||||
@@ -1085,6 +1087,11 @@ packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
||||
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
|
||||
packages/lib/components/shared/SamlShared.js
|
||||
packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js
|
||||
packages/lib/components/shared/ShareNoteDialog/types.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
|
||||
packages/lib/components/shared/SsoScreenShared.js
|
||||
packages/lib/components/shared/config/config-shared.js
|
||||
packages/lib/components/shared/config/plugins/types.js
|
||||
|
7
.gitignore
vendored
7
.gitignore
vendored
@@ -797,6 +797,8 @@ 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/ShareNoteDialog.test.js
|
||||
packages/app-mobile/components/screens/ShareNoteDialog.js
|
||||
packages/app-mobile/components/screens/SsoLoginScreen.js
|
||||
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
|
||||
packages/app-mobile/components/screens/dropbox-login.js
|
||||
@@ -1060,6 +1062,11 @@ packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/shared/NoteList/getEmptyFolderMessage.js
|
||||
packages/lib/components/shared/NoteRevisionViewer/getHelpMessage.js
|
||||
packages/lib/components/shared/SamlShared.js
|
||||
packages/lib/components/shared/ShareNoteDialog/onUnshareNoteClick.js
|
||||
packages/lib/components/shared/ShareNoteDialog/types.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useOnShareLinkClick.js
|
||||
packages/lib/components/shared/ShareNoteDialog/useShareStatusMessage.js
|
||||
packages/lib/components/shared/SsoScreenShared.js
|
||||
packages/lib/components/shared/config/config-shared.js
|
||||
packages/lib/components/shared/config/plugins/types.js
|
||||
|
@@ -1,11 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import JoplinServerApi from '@joplin/lib/JoplinServerApi';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import DialogButtonRow from './DialogButtonRow';
|
||||
import { themeStyle, buildStyle } from '@joplin/lib/theme';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import Dialog from './Dialog';
|
||||
import DialogTitle from './DialogTitle';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
@@ -14,9 +12,12 @@ import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import Button from './Button/Button';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../app.reducer';
|
||||
import { getEncryptionEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import useOnShareLinkClick from '@joplin/lib/components/shared/ShareNoteDialog/useOnShareLinkClick';
|
||||
import onUnshareNoteClick from '@joplin/lib/components/shared/ShareNoteDialog/onUnshareNoteClick';
|
||||
import useShareStatusMessage from '@joplin/lib/components/shared/ShareNoteDialog/useShareStatusMessage';
|
||||
import useEncryptionWarningMessage from '@joplin/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage';
|
||||
import { SharingStatus } from '@joplin/lib/components/shared/ShareNoteDialog/types';
|
||||
const { clipboard } = require('electron');
|
||||
|
||||
interface Props {
|
||||
@@ -72,7 +73,7 @@ function styles_(props: Props) {
|
||||
export function ShareNoteDialog(props: Props) {
|
||||
const [notes, setNotes] = useState<NoteEntity[]>([]);
|
||||
const [recursiveShare, setRecursiveShare] = useState<boolean>(false);
|
||||
const [sharesState, setSharesState] = useState<string>('unknown');
|
||||
const [sharesState, setSharesState] = useState<SharingStatus>(SharingStatus.Unknown);
|
||||
|
||||
const syncTargetInfo = useMemo(() => SyncTargetRegistry.infoById(props.syncTargetId), [props.syncTargetId]);
|
||||
const noteCount = notes.length;
|
||||
@@ -99,70 +100,20 @@ export function ShareNoteDialog(props: Props) {
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
const copyLinksToClipboard = (shares: StateShare[]) => {
|
||||
const links = [];
|
||||
for (const share of shares) links.push(ShareService.instance().shareUrl(ShareService.instance().userId, share));
|
||||
const onCopyLinks = useCallback((links: string[]) => {
|
||||
clipboard.writeText(links.join('\n'));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const shareLinkButton_click = useCallback(async () => {
|
||||
const service = ShareService.instance();
|
||||
|
||||
let hasSynced = false;
|
||||
let tryToSync = false;
|
||||
while (true) {
|
||||
try {
|
||||
if (tryToSync) {
|
||||
setSharesState('synchronizing');
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
tryToSync = false;
|
||||
hasSynced = true;
|
||||
}
|
||||
|
||||
setSharesState('creating');
|
||||
|
||||
const newShares: StateShare[] = [];
|
||||
|
||||
for (const note of notes) {
|
||||
const share = await service.shareNote(note.id, recursiveShare);
|
||||
newShares.push(share);
|
||||
}
|
||||
|
||||
setSharesState('synchronizing');
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
setSharesState('creating');
|
||||
|
||||
copyLinksToClipboard(newShares);
|
||||
|
||||
setSharesState('created');
|
||||
|
||||
await ShareService.instance().refreshShares();
|
||||
} catch (error) {
|
||||
if (error.code === 404 && !hasSynced) {
|
||||
reg.logger().info('ShareNoteDialog: Note does not exist on server - trying to sync it.', error);
|
||||
tryToSync = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
reg.logger().error('ShareNoteDialog: Cannot publish note:', error);
|
||||
|
||||
setSharesState('idle');
|
||||
void shim.showErrorDialog(JoplinServerApi.connectionErrorMessage(error));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}, [recursiveShare, notes]);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const unshareNoteButton_click = async (event: any) => {
|
||||
await ShareService.instance().unshareNote(event.noteId);
|
||||
await ShareService.instance().refreshShares();
|
||||
};
|
||||
const shareLinkButton_click = useOnShareLinkClick({
|
||||
setSharesState,
|
||||
onShareUrlsReady: onCopyLinks,
|
||||
notes,
|
||||
recursiveShare,
|
||||
});
|
||||
|
||||
const renderNote = (note: NoteEntity) => {
|
||||
const unshareButton = !props.shares.find(s => s.note_id === note.id) ? null : (
|
||||
<Button tooltip={_('Unpublish note')} iconName="fas fa-share-alt" onClick={() => unshareNoteButton_click({ noteId: note.id })}/>
|
||||
<Button tooltip={_('Unpublish note')} iconName="fas fa-share-alt" onClick={() => onUnshareNoteClick({ noteId: note.id })}/>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -172,8 +123,7 @@ export function ShareNoteDialog(props: Props) {
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
const renderNoteList = (notes: any) => {
|
||||
const renderNoteList = (notes: NoteEntity[]) => {
|
||||
const noteComps = [];
|
||||
for (const note of notes) {
|
||||
noteComps.push(renderNote(note));
|
||||
@@ -181,16 +131,12 @@ export function ShareNoteDialog(props: Props) {
|
||||
return <div style={styles.noteList}>{noteComps}</div>;
|
||||
};
|
||||
|
||||
const statusMessage = (sharesState: string): string => {
|
||||
if (sharesState === 'synchronizing') return _('Synchronising...');
|
||||
if (sharesState === 'creating') return _n('Generating link...', 'Generating links...', noteCount);
|
||||
if (sharesState === 'created') return _n('Link has been copied to clipboard!', 'Links have been copied to clipboard!', noteCount);
|
||||
return '';
|
||||
};
|
||||
const statusMessage = useShareStatusMessage({ sharesState, noteCount });
|
||||
const encryptionWarning = useEncryptionWarningMessage();
|
||||
|
||||
function renderEncryptionWarningMessage() {
|
||||
if (!getEncryptionEnabled()) return null;
|
||||
return <div style={theme.textStyle}>{_('Note: When a note is shared, it will no longer be encrypted on the server.')}<hr/></div>;
|
||||
if (!encryptionWarning) return null;
|
||||
return <div style={theme.textStyle}>{encryptionWarning}<hr/></div>;
|
||||
}
|
||||
|
||||
const onRecursiveShareChange = useCallback(() => {
|
||||
@@ -213,8 +159,8 @@ export function ShareNoteDialog(props: Props) {
|
||||
<DialogTitle title={_('Publish Notes')}/>
|
||||
{renderNoteList(notes)}
|
||||
{renderRecursiveShareCheckbox()}
|
||||
<button disabled={['creating', 'synchronizing'].indexOf(sharesState) >= 0} style={styles.copyShareLinkButton} onClick={shareLinkButton_click}>{_n('Copy Shareable Link', 'Copy Shareable Links', noteCount)}</button>
|
||||
<div style={theme.textStyle}>{statusMessage(sharesState)}</div>
|
||||
<button disabled={[SharingStatus.Creating, SharingStatus.Synchronizing].indexOf(sharesState) >= 0} style={styles.copyShareLinkButton} onClick={shareLinkButton_click}>{_n('Copy Shareable Link', 'Copy Shareable Links', noteCount)}</button>
|
||||
<div style={theme.textStyle}>{statusMessage}</div>
|
||||
{renderEncryptionWarningMessage()}
|
||||
<DialogButtonRow
|
||||
themeId={props.themeId}
|
||||
|
@@ -68,6 +68,8 @@ import getActivePluginEditorView from '@joplin/lib/services/plugins/utils/getAct
|
||||
import EditorPluginHandler from '@joplin/lib/services/plugins/EditorPluginHandler';
|
||||
import AudioRecordingBanner from '../../voiceTyping/AudioRecordingBanner';
|
||||
import SpeechToTextBanner from '../../voiceTyping/SpeechToTextBanner';
|
||||
import ShareNoteDialog from '../ShareNoteDialog';
|
||||
import stateToWhenClauseContext from '../../../services/commands/stateToWhenClauseContext';
|
||||
import { defaultWindowId } from '@joplin/lib/reducer';
|
||||
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
|
||||
|
||||
@@ -107,6 +109,7 @@ interface Props extends BaseProps {
|
||||
toolbarEnabled: boolean;
|
||||
pluginHtmlContents: PluginHtmlContents;
|
||||
editorNoteReloadTimeRequest: number;
|
||||
canPublish: boolean;
|
||||
}
|
||||
|
||||
interface ComponentProps extends Props {
|
||||
@@ -126,6 +129,7 @@ interface State {
|
||||
alarmDialogShown: boolean;
|
||||
heightBumpView: number;
|
||||
noteTagDialogShown: boolean;
|
||||
publishDialogShown: boolean;
|
||||
fromShare: boolean;
|
||||
showCamera: boolean;
|
||||
showImageEditor: boolean;
|
||||
@@ -196,6 +200,7 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
alarmDialogShown: false,
|
||||
heightBumpView: 0,
|
||||
noteTagDialogShown: false,
|
||||
publishDialogShown: false,
|
||||
fromShare: false,
|
||||
showCamera: false,
|
||||
showImageEditor: false,
|
||||
@@ -422,6 +427,18 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
};
|
||||
}
|
||||
|
||||
private onPublishDialogClose_ = () => {
|
||||
this.setState({
|
||||
publishDialogShown: false,
|
||||
});
|
||||
};
|
||||
|
||||
private onPublishDialogShow_ = () => {
|
||||
this.setState({
|
||||
publishDialogShown: true,
|
||||
});
|
||||
};
|
||||
|
||||
public styles() {
|
||||
const themeId = this.props.themeId;
|
||||
const theme = themeStyle(themeId);
|
||||
@@ -1120,6 +1137,12 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
},
|
||||
});
|
||||
}
|
||||
if (this.props.canPublish) {
|
||||
output.push({
|
||||
title: _('Publish/unpublish'),
|
||||
onPress: this.onPublishDialogShow_,
|
||||
});
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
@@ -1722,6 +1745,11 @@ class NoteScreenComponent extends BaseScreenComponent<ComponentProps, State> imp
|
||||
<SelectDateTimeDialog themeId={this.props.themeId} shown={this.state.alarmDialogShown} date={dueDate} onAccept={this.onAlarmDialogAccept} onReject={this.onAlarmDialogReject} />
|
||||
|
||||
{noteTagDialog}
|
||||
<ShareNoteDialog
|
||||
noteId={this.props.noteId}
|
||||
visible={this.state.publishDialogShown}
|
||||
onClose={this.onPublishDialogClose_}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1741,6 +1769,7 @@ const NoteScreenWrapper = (props: Props) => {
|
||||
};
|
||||
|
||||
const NoteScreen = connect((state: AppState) => {
|
||||
const whenClause = stateToWhenClauseContext(state);
|
||||
return {
|
||||
windowId: state.windowId,
|
||||
noteId: state.selectedNoteIds.length ? state.selectedNoteIds[0] : null,
|
||||
@@ -1766,6 +1795,7 @@ const NoteScreen = connect((state: AppState) => {
|
||||
// default) CodeMirror editor. That should be refactored to make it less
|
||||
// confusing.
|
||||
useEditorBeta: !state.settings['editor.usePlainText'],
|
||||
canPublish: whenClause.joplinServerConnected && !whenClause.inTrash,
|
||||
};
|
||||
})(NoteScreenWrapper);
|
||||
|
||||
|
@@ -0,0 +1,71 @@
|
||||
import * as React from 'react';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { Store } from 'redux';
|
||||
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
|
||||
import '@testing-library/jest-native/extend-expect';
|
||||
import createMockReduxStore from '../../utils/testing/createMockReduxStore';
|
||||
import setupGlobalStore from '../../utils/testing/setupGlobalStore';
|
||||
import TestProviderStack from '../testing/TestProviderStack';
|
||||
import ShareNoteDialog from './ShareNoteDialog';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import mockShareService from '@joplin/lib/testing/share/mockShareService';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react-native';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
|
||||
const mockServiceForNoteSharing = () => {
|
||||
mockShareService({
|
||||
getShares: async () => {
|
||||
return { items: [] };
|
||||
},
|
||||
postShares: async () => ({ id: 'test-id' }),
|
||||
getShareInvitations: async () => null,
|
||||
}, ShareService.instance());
|
||||
};
|
||||
|
||||
interface WrapperProps {
|
||||
noteId: string;
|
||||
onClose?: ()=> void;
|
||||
}
|
||||
|
||||
let store: Store<AppState>;
|
||||
const WrappedShareDialog: React.FC<WrapperProps> = ({
|
||||
noteId, onClose = () => {},
|
||||
}) => {
|
||||
return <TestProviderStack store={store}>
|
||||
<ShareNoteDialog
|
||||
noteId={noteId}
|
||||
visible={true}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</TestProviderStack>;
|
||||
};
|
||||
|
||||
describe('ShareNoteDialog', () => {
|
||||
beforeEach(async () => {
|
||||
await setupDatabaseAndSynchronizer(1);
|
||||
await switchClient(1);
|
||||
store = createMockReduxStore();
|
||||
setupGlobalStore(store);
|
||||
|
||||
mockServiceForNoteSharing();
|
||||
});
|
||||
|
||||
test('pressing "Copy Shareable Link" should publish the note', async () => {
|
||||
const folder = await Folder.save({ title: 'Folder' });
|
||||
const note = await Note.save({ title: 'Test', parent_id: folder.id });
|
||||
|
||||
render(<WrappedShareDialog noteId={note.id}/>);
|
||||
|
||||
const linkButton = await screen.findByRole('button', { name: 'Copy Shareable Link' });
|
||||
expect(linkButton).not.toBeDisabled();
|
||||
fireEvent.press(linkButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Link has been copied to clipboard!')).toBeVisible();
|
||||
});
|
||||
expect(await Note.load(note.id)).toMatchObject({
|
||||
is_shared: 1,
|
||||
});
|
||||
});
|
||||
});
|
208
packages/app-mobile/components/screens/ShareNoteDialog.tsx
Normal file
208
packages/app-mobile/components/screens/ShareNoteDialog.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import * as React from 'react';
|
||||
import { Button, Divider, Text } from 'react-native-paper';
|
||||
import { View, StyleSheet, ScrollView, Linking } from 'react-native';
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import { NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { StateShare } from '@joplin/lib/services/share/reducer';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
|
||||
import Clipboard from '@react-native-clipboard/clipboard';
|
||||
import useOnShareLinkClick from '@joplin/lib/components/shared/ShareNoteDialog/useOnShareLinkClick';
|
||||
import onUnshareNoteClick from '@joplin/lib/components/shared/ShareNoteDialog/onUnshareNoteClick';
|
||||
import useShareStatusMessage from '@joplin/lib/components/shared/ShareNoteDialog/useShareStatusMessage';
|
||||
import useEncryptionWarningMessage from '@joplin/lib/components/shared/ShareNoteDialog/useEncryptionWarningMessage';
|
||||
import { SharingStatus } from '@joplin/lib/components/shared/ShareNoteDialog/types';
|
||||
import { AppState } from '../../utils/types';
|
||||
import { connect } from 'react-redux';
|
||||
import DismissibleDialog, { DialogSize } from '../DismissibleDialog';
|
||||
import { _, _n } from '@joplin/lib/locale';
|
||||
import { LinkButton, PrimaryButton } from '../buttons';
|
||||
import { themeStyle } from '../global-style';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
noteId: string;
|
||||
visible: boolean;
|
||||
onClose: ()=> void;
|
||||
shares: StateShare[];
|
||||
}
|
||||
|
||||
const useStyles = (themeId: number) => {
|
||||
return useMemo(() => {
|
||||
const theme = themeStyle(themeId);
|
||||
return StyleSheet.create({
|
||||
root: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
scrollingRegion: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
noteItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
minHeight: 32,
|
||||
},
|
||||
noteTitle: {
|
||||
fontSize: theme.fontSize,
|
||||
},
|
||||
});
|
||||
}, [themeId]);
|
||||
};
|
||||
|
||||
interface UnpublishProps {
|
||||
note: NoteEntity;
|
||||
onUnpublishStart: ()=> void;
|
||||
}
|
||||
|
||||
const UnpublishButton: React.FC<UnpublishProps> = ({ note, onUnpublishStart }) => {
|
||||
const [unpublishing, setUnpublishing] = useState(false);
|
||||
const onPress = useCallback(async () => {
|
||||
onUnpublishStart();
|
||||
try {
|
||||
setUnpublishing(true);
|
||||
await onUnshareNoteClick({ noteId: note.id });
|
||||
} finally {
|
||||
setUnpublishing(false);
|
||||
}
|
||||
}, [note, onUnpublishStart]);
|
||||
|
||||
return <Button
|
||||
accessibilityLabel={_('Unpublish "%s"', note.title)}
|
||||
loading={unpublishing}
|
||||
disabled={unpublishing}
|
||||
icon='share-off'
|
||||
onPress={onPress}
|
||||
>{_('Unpublish')}</Button>;
|
||||
};
|
||||
|
||||
const ShareNoteDialogContent: React.FC<Props> = ({
|
||||
themeId, noteId, shares,
|
||||
}) => {
|
||||
const [notes, setNotes] = useState<NoteEntity[]>([]);
|
||||
const recursiveShare = false;
|
||||
const [sharesState, setSharesState] = useState<SharingStatus>(SharingStatus.Unknown);
|
||||
const [shareLinks, setShareLinks] = useState<string[]>([]);
|
||||
|
||||
const noteCount = notes.length;
|
||||
|
||||
useEffect(() => {
|
||||
void ShareService.instance().refreshShares();
|
||||
}, []);
|
||||
|
||||
useAsyncEffect(async (event) => {
|
||||
const note = await Note.load(noteId);
|
||||
if (event.cancelled) return;
|
||||
setNotes([note]);
|
||||
}, [noteId]);
|
||||
|
||||
const onCopyLinks = useCallback(async (links: string[]) => {
|
||||
setShareLinks(links);
|
||||
const linkText = links.join('\n');
|
||||
Clipboard.setString(linkText);
|
||||
}, []);
|
||||
|
||||
const onUnpublishStart = useCallback(() => {
|
||||
setShareLinks([]);
|
||||
}, []);
|
||||
|
||||
const shareLinkButton_click = useOnShareLinkClick({
|
||||
setSharesState,
|
||||
onShareUrlsReady: onCopyLinks,
|
||||
notes,
|
||||
recursiveShare,
|
||||
});
|
||||
|
||||
const styles = useStyles(themeId);
|
||||
|
||||
const renderNote = (note: NoteEntity) => {
|
||||
const unshareButton = shares.find(s => s.note_id === note.id) ? (
|
||||
<UnpublishButton note={note} onUnpublishStart={onUnpublishStart}/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<View key={note.id} style={styles.noteItem}>
|
||||
<Text style={styles.noteTitle}>{note.title}</Text>{unshareButton}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderNoteList = (notes: NoteEntity[]) => {
|
||||
const noteComps = [];
|
||||
for (const note of notes) {
|
||||
noteComps.push(renderNote(note));
|
||||
}
|
||||
return <View>{noteComps}</View>;
|
||||
};
|
||||
|
||||
const statusMessage = useShareStatusMessage({
|
||||
sharesState, noteCount,
|
||||
});
|
||||
const encryptionMessage = useEncryptionWarningMessage();
|
||||
|
||||
const renderEncryptionWarningMessage = () => {
|
||||
if (!encryptionMessage) return null;
|
||||
return <>
|
||||
<Text>{encryptionMessage}</Text>
|
||||
<Divider/>
|
||||
</>;
|
||||
};
|
||||
|
||||
const renderLinks = () => {
|
||||
if (shareLinks.length === 0) return null;
|
||||
return <>
|
||||
<Divider/>
|
||||
<Text variant='titleMedium' accessibilityRole='header'>{_('Links')}</Text>
|
||||
{shareLinks.map((link, index) => {
|
||||
return <LinkButton
|
||||
onPress={() => Linking.openURL(link)}
|
||||
key={`link-${index}`}
|
||||
>{link}</LinkButton>;
|
||||
})}
|
||||
</>;
|
||||
};
|
||||
|
||||
const copyButtonLoading = [SharingStatus.Creating, SharingStatus.Synchronizing].includes(sharesState);
|
||||
const copyLinkButton = <PrimaryButton
|
||||
disabled={copyButtonLoading}
|
||||
loading={copyButtonLoading}
|
||||
onPress={shareLinkButton_click}
|
||||
>{
|
||||
_n('Copy Shareable Link', 'Copy Shareable Links', noteCount)
|
||||
}</PrimaryButton>;
|
||||
|
||||
return <View style={styles.root}>
|
||||
<ScrollView style={styles.scrollingRegion}>
|
||||
{renderEncryptionWarningMessage()}
|
||||
<Divider/>
|
||||
{renderNoteList(notes)}
|
||||
{renderLinks()}
|
||||
</ScrollView>
|
||||
<Text aria-live='polite'>{statusMessage}</Text>
|
||||
{copyLinkButton}
|
||||
</View>;
|
||||
};
|
||||
|
||||
const ShareNoteDialog: React.FC<Props> = props => {
|
||||
return <DismissibleDialog
|
||||
themeId={props.themeId}
|
||||
visible={props.visible}
|
||||
onDismiss={props.onClose}
|
||||
size={DialogSize.Small}
|
||||
heading={_('Publish Note')}
|
||||
>
|
||||
{props.visible ? <ShareNoteDialogContent {...props}/> : null}
|
||||
</DismissibleDialog>;
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
shares: state.shareService.shares.filter(s => !!s.note_id),
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(ShareNoteDialog);
|
@@ -0,0 +1,12 @@
|
||||
import ShareService from '../../../services/share/ShareService';
|
||||
|
||||
interface UnshareNoteEvent {
|
||||
noteId: string;
|
||||
}
|
||||
|
||||
const onUnshareNoteClick = async (event: UnshareNoteEvent) => {
|
||||
await ShareService.instance().unshareNote(event.noteId);
|
||||
await ShareService.instance().refreshShares();
|
||||
};
|
||||
|
||||
export default onUnshareNoteClick;
|
9
packages/lib/components/shared/ShareNoteDialog/types.ts
Normal file
9
packages/lib/components/shared/ShareNoteDialog/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export -- types file with a single type
|
||||
export enum SharingStatus {
|
||||
Unknown = 'unknown',
|
||||
Synchronizing = 'synchronizing',
|
||||
Creating = 'creating',
|
||||
Idle = 'idle',
|
||||
Created = 'created',
|
||||
}
|
@@ -0,0 +1,9 @@
|
||||
import { _ } from '../../../locale';
|
||||
import { getEncryptionEnabled } from '../../../services/synchronizer/syncInfoUtils';
|
||||
|
||||
const useEncryptionWarningMessage = () => {
|
||||
if (!getEncryptionEnabled()) return null;
|
||||
return _('Note: When a note is shared, it will no longer be encrypted on the server.');
|
||||
};
|
||||
|
||||
export default useEncryptionWarningMessage;
|
@@ -0,0 +1,88 @@
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import JoplinServerApi from '../../../JoplinServerApi';
|
||||
import { reg } from '../../../registry';
|
||||
import { NoteEntity } from '../../../services/database/types';
|
||||
import ShareService from '../../../services/share/ShareService';
|
||||
import { StateShare } from '../../../services/share/reducer';
|
||||
import shim from '../../../shim';
|
||||
import { SharingStatus } from './types';
|
||||
const { useCallback } = shim.react();
|
||||
|
||||
const logger = Logger.create('useOnShareLinkClick');
|
||||
|
||||
interface Props {
|
||||
notes: NoteEntity[];
|
||||
recursiveShare: boolean;
|
||||
setSharesState(state: SharingStatus): void;
|
||||
onShareUrlsReady(urls: string[]): void;
|
||||
}
|
||||
|
||||
const getShareLinks = (shares: StateShare[]) => {
|
||||
const links = [];
|
||||
for (const share of shares) {
|
||||
if (!share) {
|
||||
throw new Error('Error: Empty share.');
|
||||
}
|
||||
|
||||
links.push(ShareService.instance().shareUrl(ShareService.instance().userId, share));
|
||||
}
|
||||
|
||||
return links;
|
||||
};
|
||||
|
||||
const useOnShareLinkClick = ({
|
||||
setSharesState, onShareUrlsReady, notes, recursiveShare,
|
||||
}: Props) => {
|
||||
|
||||
return useCallback(async () => {
|
||||
const service = ShareService.instance();
|
||||
|
||||
let hasSynced = false;
|
||||
let tryToSync = false;
|
||||
while (true) {
|
||||
try {
|
||||
if (tryToSync) {
|
||||
setSharesState(SharingStatus.Synchronizing);
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
tryToSync = false;
|
||||
hasSynced = true;
|
||||
}
|
||||
|
||||
setSharesState(SharingStatus.Creating);
|
||||
|
||||
const newShares: StateShare[] = [];
|
||||
|
||||
for (const note of notes) {
|
||||
const share = await service.shareNote(note.id, recursiveShare);
|
||||
newShares.push(share);
|
||||
}
|
||||
|
||||
setSharesState(SharingStatus.Synchronizing);
|
||||
await reg.waitForSyncFinishedThenSync();
|
||||
setSharesState(SharingStatus.Creating);
|
||||
|
||||
onShareUrlsReady(getShareLinks(newShares));
|
||||
|
||||
setSharesState(SharingStatus.Created);
|
||||
|
||||
await ShareService.instance().refreshShares();
|
||||
} catch (error) {
|
||||
if (error.code === 404 && !hasSynced) {
|
||||
logger.info('ShareNoteDialog: Note does not exist on server - trying to sync it.', error);
|
||||
tryToSync = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
logger.error('ShareNoteDialog: Cannot publish note:', error);
|
||||
|
||||
setSharesState(SharingStatus.Idle);
|
||||
void shim.showErrorDialog(JoplinServerApi.connectionErrorMessage(error));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}, [recursiveShare, notes, onShareUrlsReady, setSharesState]);
|
||||
};
|
||||
|
||||
export default useOnShareLinkClick;
|
@@ -0,0 +1,29 @@
|
||||
import { _, _n } from '../../../locale';
|
||||
import shim from '../../../shim';
|
||||
import { SharingStatus } from './types';
|
||||
|
||||
interface Props {
|
||||
sharesState: SharingStatus;
|
||||
noteCount: number;
|
||||
}
|
||||
|
||||
const useShareStatusMessage = ({ sharesState, noteCount }: Props): string => {
|
||||
if (sharesState === SharingStatus.Synchronizing) {
|
||||
return _('Synchronising...');
|
||||
}
|
||||
if (sharesState === SharingStatus.Creating) {
|
||||
return _n('Generating link...', 'Generating links...', noteCount);
|
||||
}
|
||||
if (sharesState === SharingStatus.Created) {
|
||||
// On web, copying text after a long delay (e.g. to sync) fails.
|
||||
// As such, the web UI for copying links is a bit different:
|
||||
if (shim.mobilePlatform() === 'web') {
|
||||
return _n('Link created!', 'Links created!', noteCount);
|
||||
} else {
|
||||
return _n('Link has been copied to clipboard!', 'Links have been copied to clipboard!', noteCount);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export default useShareStatusMessage;
|
Reference in New Issue
Block a user