feat: Editing large notes (greater than 1.5MB) will result in more optimized syncing, in which changes are saved locally immediately, but sync with the server less frequently (roughly every 30 seconds rather than after every change). (#2768)
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -28,6 +28,9 @@ describe('note view controller', () => {
|
||||
createTemplateItem: jest.fn().mockReturnValue({} as SNNote),
|
||||
} as unknown as jest.Mocked<ItemManagerInterface>,
|
||||
mutator: {} as jest.Mocked<MutatorClientInterface>,
|
||||
sessions: {
|
||||
isSignedIn: jest.fn().mockReturnValue(true),
|
||||
},
|
||||
} as unknown as jest.Mocked<WebApplication>
|
||||
|
||||
application.isNativeMobileWeb = jest.fn().mockReturnValue(false)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<React.SetStateAction<boolean>>
|
||||
children: ReactNode
|
||||
animateIcon?: boolean
|
||||
}) => (
|
||||
<div className="note-status-tooltip-container relative">
|
||||
<button
|
||||
className={classNames('peer flex h-5 w-5 items-center justify-center rounded-full', className)}
|
||||
onClick={onClick}
|
||||
onBlur={onBlur}
|
||||
aria-describedby={ElementIds.NoteStatusTooltip}
|
||||
>
|
||||
<Icon className={animateIcon ? 'animate-spin' : ''} type={icon} size="small" />
|
||||
<span className="sr-only">Note sync status</span>
|
||||
</button>
|
||||
<div
|
||||
id={ElementIds.NoteStatusTooltip}
|
||||
className={classNames(
|
||||
isTooltipVisible ? '' : 'hidden',
|
||||
'absolute right-0 top-full min-w-[90vw] translate-x-2 translate-y-1 select-none rounded border border-border',
|
||||
'bg-default px-3 py-1.5 text-left peer-hover:block peer-focus:block md:min-w-max',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
}) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<div className="note-status-tooltip-container">
|
||||
<button
|
||||
className={classNames('peer flex h-5 w-5 cursor-pointer items-center justify-center rounded-full', className)}
|
||||
onClick={onClick}
|
||||
ref={buttonRef}
|
||||
>
|
||||
<Icon className={animateIcon ? 'animate-spin' : ''} type={icon} size="small" />
|
||||
<VisuallyHidden>Note sync status</VisuallyHidden>
|
||||
</button>
|
||||
<Popover
|
||||
title="Note sync status"
|
||||
open={isTooltipVisible}
|
||||
togglePopover={() => 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}
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<IndicatorWithTooltip
|
||||
className="bg-danger text-danger-contrast"
|
||||
onClick={toggleShowPreference}
|
||||
onBlur={onBlur}
|
||||
onClick={toggleTooltip}
|
||||
icon="warning"
|
||||
isTooltipVisible={isTooltipVisible}
|
||||
setIsTooltipVisible={setIsTooltipVisible}
|
||||
>
|
||||
<div className="text-sm font-bold text-danger">{status.message}</div>
|
||||
{status.desc && <div className="mt-0.5">{status.desc}</div>}
|
||||
{status.description && <div className="mt-0.5">{status.description}</div>}
|
||||
</IndicatorWithTooltip>
|
||||
)
|
||||
}
|
||||
@@ -94,15 +110,15 @@ const NoteStatusIndicator = ({
|
||||
return (
|
||||
<IndicatorWithTooltip
|
||||
className="bg-warning text-warning-contrast"
|
||||
onClick={toggleShowPreference}
|
||||
onBlur={onBlur}
|
||||
onClick={toggleTooltip}
|
||||
icon={status && status.type === 'saving' ? 'sync' : 'warning'}
|
||||
isTooltipVisible={isTooltipVisible}
|
||||
setIsTooltipVisible={setIsTooltipVisible}
|
||||
>
|
||||
{status ? (
|
||||
<>
|
||||
<div className="text-sm font-bold text-warning">{status.message}</div>
|
||||
{status.desc && <div className="mt-0.5">{status.desc}</div>}
|
||||
{status.description && <div className="mt-0.5">{status.description}</div>}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-sm font-bold text-warning">Sync taking too long</div>
|
||||
@@ -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}
|
||||
>
|
||||
<div className="text-sm font-bold">{status.message}</div>
|
||||
{status.desc && <div className="mt-0.5">{status.desc}</div>}
|
||||
{status.description && <div className="mt-0.5">{status.description}</div>}
|
||||
{status.type === 'waiting' && note.lastSyncEnd && (
|
||||
<div className="mt-0.5">Last synced {getRelativeTimeString(note.lastSyncEnd)}</div>
|
||||
)}
|
||||
{status.type === 'waiting' ? (
|
||||
<Button
|
||||
small
|
||||
className="mt-1"
|
||||
onClick={() => {
|
||||
application.sync.sync().catch(console.error)
|
||||
toggleTooltip()
|
||||
}}
|
||||
>
|
||||
Sync now
|
||||
</Button>
|
||||
) : (
|
||||
<Button small className="mt-1" onClick={toggleShowPreference}>
|
||||
Disable status updates
|
||||
</Button>
|
||||
)}
|
||||
</IndicatorWithTooltip>
|
||||
)
|
||||
}
|
||||
@@ -133,15 +169,17 @@ const NoteStatusIndicator = ({
|
||||
return (
|
||||
<IndicatorWithTooltip
|
||||
className="bg-contrast text-passive-1"
|
||||
onClick={toggleShowPreference}
|
||||
onBlur={onBlur}
|
||||
onClick={toggleTooltip}
|
||||
icon="info"
|
||||
isTooltipVisible={isTooltipVisible}
|
||||
setIsTooltipVisible={setIsTooltipVisible}
|
||||
>
|
||||
<div className="text-sm font-bold">Note status updates are disabled</div>
|
||||
<div className="mt-0.5">Click to enable.</div>
|
||||
<Button small className="mt-1" onClick={toggleShowPreference}>
|
||||
Enable status updates
|
||||
</Button>
|
||||
</IndicatorWithTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default NoteStatusIndicator
|
||||
export default observer(NoteStatusIndicator)
|
||||
|
||||
@@ -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<NoteViewProps, State> {
|
||||
readonly controller!: NoteViewController
|
||||
|
||||
private statusTimeout?: NodeJS.Timeout
|
||||
onEditorComponentLoad?: () => void
|
||||
|
||||
private removeTrashKeyObserver?: () => void
|
||||
@@ -167,8 +165,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
;(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<NoteViewProps, State> {
|
||||
})
|
||||
|
||||
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<NoteViewProps, State> {
|
||||
}
|
||||
|
||||
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<NoteViewProps, State> {
|
||||
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<NoteViewProps, State> {
|
||||
* 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<NoteViewProps, State> {
|
||||
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<HTMLInputElement> = ({ key, currentTarget }) => {
|
||||
if (key !== KeyboardKey.Enter) {
|
||||
return
|
||||
@@ -839,6 +797,10 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
}))
|
||||
}
|
||||
|
||||
triggerSyncOnAction = () => {
|
||||
this.controller.syncNow()
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.controller.dealloced) {
|
||||
return null
|
||||
@@ -923,6 +885,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
/>
|
||||
</div>
|
||||
<NoteStatusIndicator
|
||||
note={this.note}
|
||||
status={this.state.noteStatus}
|
||||
syncTakingTooLong={this.state.syncTakingTooLong}
|
||||
updateSavingIndicator={this.state.updateSavingIndicator}
|
||||
@@ -930,6 +893,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
</div>
|
||||
{shouldShowConflictsButton && (
|
||||
<Button
|
||||
id={ElementIds.ConflictResolutionButton}
|
||||
className="flex items-center"
|
||||
primary
|
||||
colorStyle="warning"
|
||||
@@ -946,10 +910,12 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
<>
|
||||
<LinkedItemsButton
|
||||
linkingController={this.application.linkingController}
|
||||
onClick={this.triggerSyncOnAction}
|
||||
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
||||
/>
|
||||
<ChangeEditorButton
|
||||
noteViewController={this.controller}
|
||||
onClick={this.triggerSyncOnAction}
|
||||
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
||||
/>
|
||||
<PinNoteButton
|
||||
@@ -960,6 +926,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
|
||||
)}
|
||||
<NotesOptionsPanel
|
||||
notesController={this.application.notesController}
|
||||
onClick={this.triggerSyncOnAction}
|
||||
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
|
||||
onButtonBlur={() => {
|
||||
this.setState({
|
||||
|
||||
@@ -122,23 +122,20 @@ export const PlainEditor = forwardRef<PlainEditorInterface, Props>(
|
||||
needsAdjustMobileCursor.current = true
|
||||
}
|
||||
|
||||
if (lastEditorFocusEventSource.current) {
|
||||
application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: lastEditorFocusEventSource })
|
||||
}
|
||||
application.notifyWebEvent(WebAppEvent.EditorDidFocus, { eventSource: lastEditorFocusEventSource.current })
|
||||
|
||||
lastEditorFocusEventSource.current = undefined
|
||||
|
||||
onFocus()
|
||||
}, [application, isAdjustingMobileCursor, lastEditorFocusEventSource, onFocus])
|
||||
|
||||
const onContentBlur = useCallback(
|
||||
(event: FocusEvent) => {
|
||||
if (lastEditorFocusEventSource.current) {
|
||||
application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: lastEditorFocusEventSource })
|
||||
}
|
||||
lastEditorFocusEventSource.current = undefined
|
||||
|
||||
onBlur(event)
|
||||
},
|
||||
[application, lastEditorFocusEventSource, onBlur],
|
||||
[lastEditorFocusEventSource, onBlur],
|
||||
)
|
||||
|
||||
const scrollMobileCursorIntoViewAfterWebviewResize = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user