Skip to content

Commit

Permalink
Desktop: Show notification on import/export using interop service
Browse files Browse the repository at this point in the history
When importing/exporting items in the desktop app, a giant
model layer covers all our screen, and the users are forced
to wait until the process is completed. This raised our
attention and the need of implementing something that would let
the users do their things, while still being able to know about
when the process is happening and when it is finished.

We though and decided that one possible solution would be using
the notyf library, which right now is only used by the
TrashNotification component, and show a simple and minimal
notification indicating the state of the process, instead of
covering the entire screen with a modal layer. We truly
believe that this would drastically increase the user
experience.

Co-authored-by: Henrique Silva <[email protected]>
  • Loading branch information
fabiogvdneto and henriqueeapsilva committed May 29, 2024
1 parent f1eeeab commit 333d610
Show file tree
Hide file tree
Showing 7 changed files with 247 additions and 16 deletions.
16 changes: 12 additions & 4 deletions packages/app-desktop/InteropServiceHelper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import InteropService from '@joplin/lib/services/interop/InteropService';
import CommandService from '@joplin/lib/services/CommandService';
import shim from '@joplin/lib/shim';
import { ExportModuleOutputFormat, ExportOptions, FileSystemItem } from '@joplin/lib/services/interop/types';
import { ExportModule } from '@joplin/lib/services/interop/Module';
Expand Down Expand Up @@ -196,7 +195,12 @@ export default class InteropServiceHelper {

if (Array.isArray(path)) path = path[0];

void CommandService.instance().execute('showModalMessage', _('Exporting to "%s" as "%s" format. Please wait...', path, module.format));
_dispatch({
type: 'INTEROP_EXEC',
operation: 'export',
path: path,
format: module.format,
});

const exportOptions: ExportOptions = {};
exportOptions.path = path;
Expand All @@ -215,12 +219,16 @@ export default class InteropServiceHelper {
const result = await service.export(exportOptions);
// eslint-disable-next-line no-console
console.info('Export result: ', result);

_dispatch({
type: 'INTEROP_COMPLETE',
operation: 'export',
path: path,
});
} catch (error) {
console.error(error);
bridge().showErrorMessageBox(_('Could not export notes: %s', error.message));
}

void CommandService.instance().execute('hideModalMessage');
}

}
56 changes: 56 additions & 0 deletions packages/app-desktop/app.reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import iterateItems from './gui/ResizableLayout/utils/iterateItems';
import { LayoutItem } from './gui/ResizableLayout/utils/types';
import validateLayout from './gui/ResizableLayout/utils/validateLayout';
import Logger from '@joplin/utils/Logger';
import { NotyfNotification } from 'notyf';

const logger = Logger.create('app.reducer');

Expand All @@ -30,6 +31,14 @@ export interface EditorScrollPercents {
[noteId: string]: number;
}

export interface AppStateInterop {
operation: string;
path: string;
format: string;
completed: boolean;
notification: NotyfNotification;
}

export interface AppState extends State {
route: AppStateRoute;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
Expand All @@ -52,6 +61,7 @@ export interface AppState extends State {
mainLayout: LayoutItem;
dialogs: AppStateDialog[];
isResettingLayout: boolean;
interop: AppStateInterop[];
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
Expand All @@ -76,6 +86,7 @@ export function createAppDefaultState(windowContentSize: any, resourceEditWatche
startupPluginsLoaded: false,
dialogs: [],
isResettingLayout: false,
interop: [],
...resourceEditWatcherDefaultState,
};
}
Expand All @@ -87,6 +98,51 @@ export default function(state: AppState, action: any) {
try {
switch (action.type) {

case 'INTEROP_EXEC':

{
const interop = newState.interop.slice();

interop.push({
operation: action.operation,
path: action.path,
format: action.format,
completed: false,
notification: undefined,
});

newState = { ...state, interop: interop };
}
break;

case 'INTEROP_COMPLETE':

{
const interop = newState.interop.slice();
const operation = interop.find(interop => interop.operation === action.operation && interop.path === action.path);
const newInterop = interop.filter(interop => !(interop.operation === action.operation && interop.path === action.path));

newInterop.push({ ...operation, completed: true });

newState = { ...state, interop: newInterop };
}
break;

case 'INTEROP_NOTIFICATION_DONE':

{
const interop = newState.interop.slice();
const operation = interop.find(interop => interop.operation === action.operation && interop.path === action.path);
const newInterop = interop.filter(interop => !(interop.operation === action.operation && interop.path === action.path));

if (!operation.completed) {
newInterop.push({ ...operation, notification: action.notification });
}

newState = { ...state, interop: newInterop };
}
break;

case 'NAV_BACK':
case 'NAV_GO':

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { useContext, useMemo } from 'react';
import { _ } from '@joplin/lib/locale';
import NotyfContext from '../NotyfContext';
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import { themeStyle } from '@joplin/lib/theme';
import { Dispatch } from 'redux';
import { AppStateInterop } from '../../app.reducer';
import { NotyfNotification } from 'notyf';

interface Props {
interop: AppStateInterop[];
themeId: number;
dispatch: Dispatch;
}

export default (props: Props) => {
const notyfContext = useContext(NotyfContext);

const theme = useMemo(() => {
return themeStyle(props.themeId);
}, [props.themeId]);

const notyf = useMemo(() => {
const output = notyfContext;
output.options.types = notyfContext.options.types.map(type => {
if (type.type === 'success') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(type.icon as any).color = theme.backgroundColor5;
} else if (type.type === 'error') {
type.background = theme.backgroundColor5;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
(type.icon as any).color = theme.backgroundColor5;
} else if (type.type === 'loading') {
type.background = theme.backgroundColor5;
}

return type;
});
return output;
}, [notyfContext, theme]);

useAsyncEffect(async (_event) => {
for (const op of props.interop) {
if (op.notification && !op.completed) return;

let msg = '';

if (op.operation === 'export') {
if (op.completed) {
msg = _('Successfully exported to %s.', op.path);
} else {
msg = _('Exporting to "%s" as "%s" format. Please wait...', op.path, op.format);
}
} else {
if (op.completed) {
msg = _('Successfully imported from %s.', op.path);
} else {
msg = _('Importing from "%s" as "%s" format. Please wait...', op.path, op.format);
}
}

let duration = 0;
let notyfType = 'loading';

if (op.completed) {
notyf.dismiss(op.notification);
duration = 6000;
notyfType = 'success';
}

const notification: NotyfNotification = notyf.open({
type: notyfType,
message: `${msg}`,
duration: duration,
dismissible: true,
});

props.dispatch({
type: 'INTEROP_NOTIFICATION_DONE',
operation: op.operation,
path: op.path,
notification: notification,
});
}
}, [notyf, props.dispatch, props.interop]);

return <div style={{ display: 'none' }}/>;
};
54 changes: 54 additions & 0 deletions packages/app-desktop/gui/InteropNotification/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
body .notyf {
color: var(--joplin-color5);
}

.notyf__toast {

> .notyf__wrapper {

> .notyf__message {
display: flex;
align-items: center;
justify-content: space-between;

> .cancel {
color: var(--joplin-color5);
text-decoration: underline;
display: flex;
align-items: center;
}

> .cancel::after {
content: '';
margin-left: 8px; /* Adjust the space between text and cross */
}
}

> .notyf__icon {
width: 24px;
height: 24px;

> .notyf__icon--success {
background-color: var(--joplin-color5);
/* Add an icon for success */
//background-image: url('path-to-success-icon.svg');
//background-size: cover;
}

> .notyf__icon--error {
background-color: var(--joplin-color5);
/* Add an icon for error */
//background-image: url('path-to-error-icon.svg');
//background-size: cover;
}

> .notyf__icon--loading {
background-color: var(--joplin-color5);
/* Add an icon for loading */
//background-image: url('path-to-loading-icon.svg');
//background-size: cover;
}
}

}
}
13 changes: 12 additions & 1 deletion packages/app-desktop/gui/MainScreen/MainScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { StateLastDeletion, stateUtils } from '@joplin/lib/reducer';
import InteropServiceHelper from '../../InteropServiceHelper';
import { _ } from '@joplin/lib/locale';
import NoteListWrapper from '../NoteListWrapper/NoteListWrapper';
import { AppState } from '../../app.reducer';
import { AppStateInterop, AppState } from '../../app.reducer';
import { saveLayout, loadLayout } from '../ResizableLayout/utils/persist';
import Setting from '@joplin/lib/models/Setting';
import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning';
Expand Down Expand Up @@ -48,6 +48,7 @@ import NotePropertiesDialog from '../NotePropertiesDialog';
import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType';
import validateColumns from '../NoteListHeader/utils/validateColumns';
import TrashNotification from '../TrashNotification/TrashNotification';
import InteropNotification from '../InteropNotification/InteropNotification';

const PluginManager = require('@joplin/lib/services/PluginManager');
const ipcRenderer = require('electron').ipcRenderer;
Expand Down Expand Up @@ -97,6 +98,7 @@ interface Props {
notesSortOrderField: string;
notesSortOrderReverse: boolean;
notesColumns: NoteListColumns;
interop: AppStateInterop[];
}

interface ShareFolderDialogOptions {
Expand Down Expand Up @@ -913,13 +915,21 @@ class MainScreenComponent extends React.Component<Props, State> {

<PromptDialog autocomplete={promptOptions && 'autocomplete' in promptOptions ? promptOptions.autocomplete : null} defaultValue={promptOptions && promptOptions.value ? promptOptions.value : ''} themeId={this.props.themeId} style={styles.prompt} onClose={this.promptOnClose_} label={promptOptions ? promptOptions.label : ''} description={promptOptions ? promptOptions.description : null} visible={!!this.state.promptOptions} buttons={promptOptions && 'buttons' in promptOptions ? promptOptions.buttons : null} inputType={promptOptions && 'inputType' in promptOptions ? promptOptions.inputType : null} />

<InteropNotification
interop={this.props.interop}
themeId={this.props.themeId}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
dispatch={this.props.dispatch as any}
/>

<TrashNotification
lastDeletion={this.props.lastDeletion}
lastDeletionNotificationTime={this.props.lastDeletionNotificationTime}
themeId={this.props.themeId}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
dispatch={this.props.dispatch as any}
/>

{messageComp}
{layoutComp}
{pluginDialog}
Expand Down Expand Up @@ -965,6 +975,7 @@ const mapStateToProps = (state: AppState) => {
notesSortOrderField: state.settings['notes.sortOrder.field'],
notesSortOrderReverse: state.settings['notes.sortOrder.reverse'],
notesColumns: validateColumns(state.settings['notes.columns']),
interop: state.interop,
};
};

Expand Down
30 changes: 19 additions & 11 deletions packages/app-desktop/gui/MenuBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,10 +308,6 @@ function useMenu(props: Props) {

if (Array.isArray(path)) path = path[0];

const modalMessage = _('Importing from "%s" as "%s" format. Please wait...', path, module.format);

void CommandService.instance().execute('showModalMessage', modalMessage);

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const errors: any[] = [];

Expand All @@ -320,12 +316,13 @@ function useMenu(props: Props) {
format: module.format,
outputFormat: module.outputFormat,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onProgress: (status: any) => {
const statusStrings: string[] = Object.keys(status).map((key: string) => {
return `${key}: ${status[key]}`;
onProgress: (_status: any) => {
props.dispatch({
type: 'INTEROP_EXEC',
operation: 'import',
path: path,
format: module.format,
});

void CommandService.instance().execute('showModalMessage', `${modalMessage}\n\n${statusStrings.join('\n')}`);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onError: (error: any) => {
Expand All @@ -335,17 +332,28 @@ function useMenu(props: Props) {
destinationFolderId: !module.isNoteArchive && moduleSource === 'file' ? props.selectedFolderId : null,
};

props.dispatch({
type: 'INTEROP_EXEC',
operation: 'import',
path: path,
format: module.format,
});

const service = InteropService.instance();
try {
const result = await service.import(importOptions);
// eslint-disable-next-line no-console
console.info('Import result: ', result);

props.dispatch({
type: 'INTEROP_COMPLETE',
operation: 'import',
path: path,
});
} catch (error) {
bridge().showErrorMessageBox(error.message);
}

void CommandService.instance().execute('hideModalMessage');

if (errors.length) {
const response = bridge().showErrorMessageBox('There was some errors importing the notes - check the console for more details.\n\nPlease consider sending a bug report to the forum!', {
buttons: [_('Close'), _('Send bug report')],
Expand Down
Loading

0 comments on commit 333d610

Please sign in to comment.