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:
Aman Harwara
2024-01-20 15:22:09 +05:30
committed by GitHub
parent c6060aaab3
commit 396ee3f449
32 changed files with 408 additions and 163 deletions

View File

@@ -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,
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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)

View File

@@ -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({

View File

@@ -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(() => {