diff --git a/packages/icons/src/Icons/ic-clock.svg b/packages/icons/src/Icons/ic-clock.svg new file mode 100644 index 000000000..86614f344 --- /dev/null +++ b/packages/icons/src/Icons/ic-clock.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/icons/src/Icons/index.ts b/packages/icons/src/Icons/index.ts index 2820c656a..b258178fc 100644 --- a/packages/icons/src/Icons/index.ts +++ b/packages/icons/src/Icons/index.ts @@ -43,6 +43,7 @@ import ChevronUpIcon from './ic-chevron-up.svg' import CircleIcon from './circle-55.svg' import ClearCircleFilledIcon from './ic-clear-circle-filled.svg' import CloseCircleFilledIcon from './ic-close-circle-filled.svg' +import ClockIcon from './ic-clock.svg' import CloseIcon from './ic-close.svg' import CloudOffIcon from './ic-cloud-off.svg' import CodeIcon from './ic-code.svg' @@ -263,6 +264,7 @@ export { CircleIcon, ClearCircleFilledIcon, CloseCircleFilledIcon, + ClockIcon, CloseIcon, CloudOffIcon, CodeIcon, diff --git a/packages/models/src/Domain/Utilities/Icon/IconType.ts b/packages/models/src/Domain/Utilities/Icon/IconType.ts index 615ca7840..1098fc6df 100644 --- a/packages/models/src/Domain/Utilities/Icon/IconType.ts +++ b/packages/models/src/Domain/Utilities/Icon/IconType.ts @@ -37,6 +37,7 @@ export type IconType = | 'chevron-right' | 'chevron-up' | 'clear-circle-filled' + | 'clock' | 'close' | 'cloud-off' | 'code-2' diff --git a/packages/services/src/Domain/Event/WebAppEvent.ts b/packages/services/src/Domain/Event/WebAppEvent.ts index 1bcce80ce..265f4ee35 100644 --- a/packages/services/src/Domain/Event/WebAppEvent.ts +++ b/packages/services/src/Domain/Event/WebAppEvent.ts @@ -1,6 +1,6 @@ export enum WebAppEvent { NewUpdateAvailable = 'NewUpdateAvailable', - EditorFocused = 'EditorFocused', + EditorDidFocus = 'EditorDidFocus', BeganBackupDownload = 'BeganBackupDownload', EndedBackupDownload = 'EndedBackupDownload', PanelResized = 'PanelResized', diff --git a/packages/services/src/Domain/Sync/SyncMode.ts b/packages/services/src/Domain/Sync/SyncMode.ts index 7a813c672..0b5f05441 100644 --- a/packages/services/src/Domain/Sync/SyncMode.ts +++ b/packages/services/src/Domain/Sync/SyncMode.ts @@ -11,4 +11,5 @@ export enum SyncMode { * all data to see if user has an items key, and if not, only then create a new one. */ DownloadFirst = 'DownloadFirst', + LocalOnly = 'LocalOnly', } diff --git a/packages/snjs/lib/Services/Sync/SyncService.ts b/packages/snjs/lib/Services/Sync/SyncService.ts index dca3b962f..12963133d 100644 --- a/packages/snjs/lib/Services/Sync/SyncService.ts +++ b/packages/snjs/lib/Services/Sync/SyncService.ts @@ -896,6 +896,12 @@ export class SyncService const { items, beginDate, frozenDirtyIndex, neverSyncedDeleted } = await this.prepareForSync(options) + if (options.mode === SyncMode.LocalOnly) { + this.logger.debug('Syncing local only, skipping remote sync request') + releaseLock() + return + } + const inTimeResolveQueue = this.getPendingRequestsMadeInTimeToPiggyBackOnCurrentRequest() if (!shouldExecuteSync) { diff --git a/packages/web/src/javascripts/Application/VisibilityObserver.ts b/packages/web/src/javascripts/Application/VisibilityObserver.ts index f610b39f2..8a26afeee 100644 --- a/packages/web/src/javascripts/Application/VisibilityObserver.ts +++ b/packages/web/src/javascripts/Application/VisibilityObserver.ts @@ -11,6 +11,7 @@ export class VisibilityObserver { */ document.addEventListener('visibilitychange', this.onVisibilityChange) window.addEventListener('focus', this.onFocusEvent, false) + window.addEventListener('blur', this.onBlurEvent, false) } onVisibilityChange = () => { @@ -23,6 +24,10 @@ export class VisibilityObserver { this.notifyEvent(WebAppEvent.WindowDidFocus) } + onBlurEvent = () => { + this.notifyEvent(WebAppEvent.WindowDidBlur) + } + private notifyEvent(event: WebAppEvent): void { if (this.raceTimeout) { clearTimeout(this.raceTimeout) @@ -35,6 +40,7 @@ export class VisibilityObserver { deinit(): void { document.removeEventListener('visibilitychange', this.onVisibilityChange) window.removeEventListener('focus', this.onFocusEvent) + window.removeEventListener('blur', this.onBlurEvent) ;(this.onEvent as unknown) = undefined } } diff --git a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx index 2f968342d..e31c2547b 100644 --- a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx +++ b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx @@ -168,7 +168,7 @@ const ApplicationView: FunctionComponent = ({ application, mainApplicatio useEffect(() => { const removeObserver = application.addWebEventObserver(async (eventName) => { - if (eventName === WebAppEvent.WindowDidFocus) { + if (eventName === WebAppEvent.WindowDidFocus || eventName === WebAppEvent.WindowDidBlur) { if (!(await application.protections.isLocked())) { application.sync.sync().catch(console.error) } diff --git a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx index a20ca4b4b..23e8443d3 100644 --- a/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx +++ b/packages/web/src/javascripts/Components/ChangeEditor/ChangeEditorButton.tsx @@ -11,10 +11,11 @@ import { NoteType, noteTypeForEditorIdentifier } from '@standardnotes/snjs' type Props = { noteViewController?: NoteViewController + onClick?: () => void onClickPreprocessing?: () => Promise } -const ChangeEditorButton: FunctionComponent = ({ noteViewController, onClickPreprocessing }: Props) => { +const ChangeEditorButton: FunctionComponent = ({ noteViewController, onClick, onClickPreprocessing }: Props) => { const application = useApplication() const note = application.notesController.firstSelectedNote @@ -48,7 +49,10 @@ const ChangeEditorButton: FunctionComponent = ({ noteViewController, onCl await onClickPreprocessing() } setIsOpen(willMenuOpen) - }, [onClickPreprocessing, isOpen]) + if (onClick) { + onClick() + } + }, [isOpen, onClickPreprocessing, onClick]) useEffect(() => { return application.keyboardService.addCommandHandler({ diff --git a/packages/web/src/javascripts/Components/Footer/Footer.tsx b/packages/web/src/javascripts/Components/Footer/Footer.tsx index af70cd5c7..29ae3ed79 100644 --- a/packages/web/src/javascripts/Components/Footer/Footer.tsx +++ b/packages/web/src/javascripts/Components/Footer/Footer.tsx @@ -70,7 +70,7 @@ class Footer extends AbstractComponent { case WebAppEvent.NewUpdateAvailable: this.onNewUpdateAvailable() break - case WebAppEvent.EditorFocused: + case WebAppEvent.EditorDidFocus: // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((data as any).eventSource === EditorEventSource.UserInteraction) { this.closeAccountMenu() diff --git a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx index 89a152698..93cec6fba 100644 --- a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx +++ b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx @@ -84,6 +84,7 @@ export const IconNameToSvgMapping = { bold: icons.BoldIcon, camera: icons.CameraIcon, check: icons.CheckIcon, + clock: icons.ClockIcon, close: icons.CloseIcon, code: icons.CodeIcon, comment: icons.FeedbackIcon, diff --git a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx index 1c5e0da82..03a15f967 100644 --- a/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx +++ b/packages/web/src/javascripts/Components/LinkedItems/LinkedItemsButton.tsx @@ -7,10 +7,11 @@ import LinkedItemsPanel from './LinkedItemsPanel' type Props = { linkingController: LinkingController + onClick?: () => void onClickPreprocessing?: () => Promise } -const LinkedItemsButton = ({ linkingController, onClickPreprocessing }: Props) => { +const LinkedItemsButton = ({ linkingController, onClick, onClickPreprocessing }: Props) => { const { activeItem, isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController const buttonRef = useRef(null) @@ -20,7 +21,10 @@ const LinkedItemsButton = ({ linkingController, onClickPreprocessing }: Props) = await onClickPreprocessing() } setIsLinkingPanelOpen(willMenuOpen) - }, [isLinkingPanelOpen, onClickPreprocessing, setIsLinkingPanelOpen]) + if (onClick) { + onClick() + } + }, [isLinkingPanelOpen, onClick, onClickPreprocessing, setIsLinkingPanelOpen]) if (!activeItem) { return null diff --git a/packages/web/src/javascripts/Components/NoteView/Controller/EditorSaveTimeoutDebounce.ts b/packages/web/src/javascripts/Components/NoteView/Controller/EditorSaveTimeoutDebounce.ts index 836d6fc6f..d3295a9e2 100644 --- a/packages/web/src/javascripts/Components/NoteView/Controller/EditorSaveTimeoutDebounce.ts +++ b/packages/web/src/javascripts/Components/NoteView/Controller/EditorSaveTimeoutDebounce.ts @@ -1,5 +1,8 @@ +import { MILLISECONDS_IN_A_SECOND } from '@/Constants/Constants' + export const EditorSaveTimeoutDebounce = { Desktop: 350, ImmediateChange: 100, NativeMobileWeb: 700, + LargeNote: 60 * MILLISECONDS_IN_A_SECOND, } diff --git a/packages/web/src/javascripts/Components/NoteView/Controller/ItemGroupController.ts b/packages/web/src/javascripts/Components/NoteView/Controller/ItemGroupController.ts index 35faa2dbe..101c80404 100644 --- a/packages/web/src/javascripts/Components/NoteView/Controller/ItemGroupController.ts +++ b/packages/web/src/javascripts/Components/NoteView/Controller/ItemGroupController.ts @@ -104,6 +104,9 @@ export class ItemGroupController { controller: NoteViewController | FileViewController, { notify = true }: { notify: boolean } = { notify: true }, ): void { + if (controller instanceof NoteViewController) { + controller.syncOnlyIfLargeNote() + } controller.deinit() removeFromArray(this.itemControllers, controller) diff --git a/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts b/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts index 48d2a5549..169a50a9a 100644 --- a/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts +++ b/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.spec.ts @@ -28,6 +28,9 @@ describe('note view controller', () => { createTemplateItem: jest.fn().mockReturnValue({} as SNNote), } as unknown as jest.Mocked, mutator: {} as jest.Mocked, + sessions: { + isSignedIn: jest.fn().mockReturnValue(true), + }, } as unknown as jest.Mocked application.isNativeMobileWeb = jest.fn().mockReturnValue(false) diff --git a/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.ts b/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.ts index 9cbf27808..615f60d5f 100644 --- a/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.ts +++ b/packages/web/src/javascripts/Components/NoteView/Controller/NoteViewController.ts @@ -25,6 +25,7 @@ import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerO import { log, LoggingDomain } from '@/Logging' import { NoteSaveFunctionParams, NoteSyncController } from '../../../Controllers/NoteSyncController' import { IsNativeMobileWeb } from '@standardnotes/ui-services' +import { NoteStatus } from '../NoteStatusIndicator' export type EditorValues = { title: string @@ -219,4 +220,28 @@ export class NoteViewController implements ItemViewControllerInterface { await this.syncController.saveAndAwaitLocalPropagation(params) } + + public get syncStatus(): NoteStatus | undefined { + return this.syncController.status + } + + public showSavingStatus(): void { + this.syncController.showSavingStatus() + } + + public showAllChangesSavedStatus(): void { + this.syncController.showAllChangesSavedStatus() + } + + public showErrorSyncStatus(error?: NoteStatus): void { + this.syncController.showErrorStatus(error) + } + + public syncNow(): void { + this.sync.sync().catch(console.error) + } + + public syncOnlyIfLargeNote(): void { + this.syncController.syncOnlyIfLargeNote() + } } diff --git a/packages/web/src/javascripts/Components/NoteView/NoteStatusIndicator.tsx b/packages/web/src/javascripts/Components/NoteView/NoteStatusIndicator.tsx index 2aee949aa..c78e7dd94 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteStatusIndicator.tsx +++ b/packages/web/src/javascripts/Components/NoteView/NoteStatusIndicator.tsx @@ -1,63 +1,77 @@ -import { ElementIds } from '@/Constants/ElementIDs' import { classNames } from '@standardnotes/utils' -import { ReactNode, useCallback, useState } from 'react' -import { IconType, PrefKey, PrefDefaults } from '@standardnotes/snjs' +import { ReactNode, useCallback, useRef, useState } from 'react' +import { IconType, PrefKey, PrefDefaults, SNNote } from '@standardnotes/snjs' import Icon from '../Icon/Icon' import { useApplication } from '../ApplicationProvider' +import { observer } from 'mobx-react-lite' +import { VisuallyHidden } from '@ariakit/react' +import Button from '../Button/Button' +import Popover from '../Popover/Popover' +import { getRelativeTimeString } from '@/Utils/GetRelativeTimeString' export type NoteStatus = { - type: 'saving' | 'saved' | 'error' + type: 'saving' | 'saved' | 'error' | 'waiting' message: string - desc?: string + description?: ReactNode } const IndicatorWithTooltip = ({ className, onClick, - onBlur, icon, isTooltipVisible, + setIsTooltipVisible, children, animateIcon = false, }: { className: string onClick: () => void - onBlur: () => void icon: IconType isTooltipVisible: boolean + setIsTooltipVisible: React.Dispatch> children: ReactNode animateIcon?: boolean -}) => ( -
- -
- {children} +}) => { + const buttonRef = useRef(null) + + return ( +
+ + setIsTooltipVisible((visible) => !visible)} + className="px-3 py-2" + containerClassName="!min-w-0 !w-auto max-w-[90vw]" + anchorElement={buttonRef} + side="bottom" + align="center" + offset={6} + disableMobileFullscreenTakeover + disableApplyingMobileWidth + > + {children} +
-
-) + ) +} type Props = { + note: SNNote status: NoteStatus | undefined syncTakingTooLong: boolean updateSavingIndicator?: boolean } const NoteStatusIndicator = ({ + note, status, syncTakingTooLong, updateSavingIndicator = PrefDefaults[PrefKey.UpdateSavingStatusIndicator], @@ -65,7 +79,9 @@ const NoteStatusIndicator = ({ const application = useApplication() const [isTooltipVisible, setIsTooltipVisible] = useState(false) - const onBlur = () => setIsTooltipVisible(false) + const toggleTooltip = useCallback(() => { + setIsTooltipVisible((visible) => !visible) + }, []) const toggleShowPreference = useCallback(() => { void application.setPreference(PrefKey.UpdateSavingStatusIndicator, !updateSavingIndicator) @@ -79,13 +95,13 @@ const NoteStatusIndicator = ({ return (
{status.message}
- {status.desc &&
{status.desc}
} + {status.description &&
{status.description}
}
) } @@ -94,15 +110,15 @@ const NoteStatusIndicator = ({ return ( {status ? ( <>
{status.message}
- {status.desc &&
{status.desc}
} + {status.description &&
{status.description}
} ) : (
Sync taking too long
@@ -117,15 +133,35 @@ const NoteStatusIndicator = ({ className={classNames( status.type === 'saving' && 'bg-contrast', status.type === 'saved' && 'bg-success text-success-contrast', + status.type === 'waiting' && 'bg-warning text-warning-contrast', )} - onClick={toggleShowPreference} - onBlur={onBlur} - icon={status.type === 'saving' ? 'sync' : 'check'} + onClick={toggleTooltip} + icon={status.type === 'saving' ? 'sync' : status.type === 'waiting' ? 'clock' : 'check'} animateIcon={status.type === 'saving'} isTooltipVisible={isTooltipVisible} + setIsTooltipVisible={setIsTooltipVisible} >
{status.message}
- {status.desc &&
{status.desc}
} + {status.description &&
{status.description}
} + {status.type === 'waiting' && note.lastSyncEnd && ( +
Last synced {getRelativeTimeString(note.lastSyncEnd)}
+ )} + {status.type === 'waiting' ? ( + + ) : ( + + )}
) } @@ -133,15 +169,17 @@ const NoteStatusIndicator = ({ return (
Note status updates are disabled
-
Click to enable.
+
) } -export default NoteStatusIndicator +export default observer(NoteStatusIndicator) diff --git a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx index ced79bf7f..776cef8e3 100644 --- a/packages/web/src/javascripts/Components/NoteView/NoteView.tsx +++ b/packages/web/src/javascripts/Components/NoteView/NoteView.tsx @@ -54,8 +54,6 @@ import ModalOverlay from '../Modal/ModalOverlay' import NoteConflictResolutionModal from './NoteConflictResolutionModal/NoteConflictResolutionModal' import Icon from '../Icon/Icon' -const MinimumStatusDuration = 400 - function sortAlphabetically(array: ComponentInterface[]): ComponentInterface[] { return array.sort((a, b) => (a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1)) } @@ -83,6 +81,7 @@ type State = { updateSavingIndicator?: boolean editorFeatureIdentifier?: string noteType?: NoteType + focusModeEnabled?: boolean conflictedNotes: SNNote[] showConflictResolutionModal: boolean @@ -91,7 +90,6 @@ type State = { class NoteView extends AbstractComponent { readonly controller!: NoteViewController - private statusTimeout?: NodeJS.Timeout onEditorComponentLoad?: () => void private removeTrashKeyObserver?: () => void @@ -167,8 +165,6 @@ class NoteView extends AbstractComponent { ;(this.ensureNoteIsInsertedBeforeUIAction as unknown) = undefined this.onEditorComponentLoad = undefined - - this.statusTimeout = undefined ;(this.onPanelResizeFinish as unknown) = undefined ;(this.authorizeAndDismissProtectedWarning as unknown) = undefined ;(this.editorComponentViewerRequestsReload as unknown) = undefined @@ -235,9 +231,22 @@ class NoteView extends AbstractComponent { }) this.autorun(() => { + const syncStatus = this.controller.syncStatus + + const isFocusModeEnabled = this.application.paneController.focusModeEnabled + const didFocusModeChange = this.state.focusModeEnabled !== isFocusModeEnabled + this.setState({ showProtectedWarning: this.application.notesController.showProtectedWarning, + noteStatus: syncStatus, + saveError: syncStatus?.type === 'error', + syncTakingTooLong: false, + focusModeEnabled: isFocusModeEnabled, }) + + if (!isFocusModeEnabled && didFocusModeChange) { + this.controller.syncOnlyIfLargeNote() + } }) this.reloadEditorComponent().catch(console.error) @@ -327,16 +336,18 @@ class NoteView extends AbstractComponent { } if (note.lastSyncBegan || note.dirty) { + const currentStatus = this.controller.syncStatus + const isWaitingToSyncLargeNote = currentStatus?.type === 'waiting' if (note.lastSyncEnd) { - const shouldShowSavingStatus = note.lastSyncBegan && note.lastSyncBegan.getTime() > note.lastSyncEnd.getTime() + const hasStartedNewSync = note.lastSyncBegan && note.lastSyncBegan.getTime() > note.lastSyncEnd.getTime() const shouldShowSavedStatus = note.lastSyncBegan && note.lastSyncEnd.getTime() > note.lastSyncBegan.getTime() - if (note.dirty || shouldShowSavingStatus) { - this.showSavingStatus() - } else if (this.state.noteStatus && shouldShowSavedStatus) { - this.showAllChangesSavedStatus() + if (hasStartedNewSync) { + this.controller.showSavingStatus() + } else if (this.state.noteStatus && shouldShowSavedStatus && !isWaitingToSyncLargeNote) { + this.controller.showAllChangesSavedStatus() } - } else { - this.showSavingStatus() + } else if (note.lastSyncBegan) { + this.controller.showSavingStatus() } } } @@ -372,7 +383,7 @@ class NoteView extends AbstractComponent { const isInErrorState = this.state.saveError /** if we're still dirty, don't change status, a sync is likely upcoming. */ if (!this.note.dirty && isInErrorState) { - this.showAllChangesSavedStatus() + this.controller.showAllChangesSavedStatus() } break } @@ -383,14 +394,14 @@ class NoteView extends AbstractComponent { * and we don't want to display an error here. */ if (this.note.dirty) { - this.showErrorStatus() + this.controller.showErrorSyncStatus() } break case ApplicationEvent.LocalDatabaseWriteError: - this.showErrorStatus({ + this.controller.showErrorSyncStatus({ type: 'error', message: 'Offline Saving Issue', - desc: 'Changes not saved', + description: 'Changes not saved', }) break case ApplicationEvent.UnprotectedSessionBegan: { @@ -551,59 +562,6 @@ class NoteView extends AbstractComponent { return this.application.actions.extensionsInContextOfItem(this.note).length > 0 } - showSavingStatus() { - this.setStatus({ type: 'saving', message: 'Saving…' }, false) - } - - showAllChangesSavedStatus() { - this.setState({ - saveError: false, - syncTakingTooLong: false, - }) - this.setStatus({ - type: 'saved', - message: 'All changes saved' + (this.application.sessions.isSignedOut() ? ' offline' : ''), - }) - } - - showErrorStatus(error?: NoteStatus) { - if (!error) { - error = { - type: 'error', - message: 'Sync Unreachable', - desc: 'Changes saved offline', - } - } - this.setState({ - saveError: true, - syncTakingTooLong: false, - }) - this.setStatus(error) - } - - setStatus(status: NoteStatus, wait = true) { - if (this.statusTimeout) { - clearTimeout(this.statusTimeout) - } - if (wait) { - this.statusTimeout = setTimeout(() => { - this.setState({ - noteStatus: status, - }) - }, MinimumStatusDuration) - } else { - this.setState({ - noteStatus: status, - }) - } - } - - cancelPendingSetStatus() { - if (this.statusTimeout) { - clearTimeout(this.statusTimeout) - } - } - onTitleEnter: KeyboardEventHandler = ({ key, currentTarget }) => { if (key !== KeyboardKey.Enter) { return @@ -839,6 +797,10 @@ class NoteView extends AbstractComponent { })) } + triggerSyncOnAction = () => { + this.controller.syncNow() + } + override render() { if (this.controller.dealloced) { return null @@ -923,6 +885,7 @@ class NoteView extends AbstractComponent { />
{ {shouldShowConflictsButton && (