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

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="currentColor" d="M12 20a8 8 0 0 0 8-8a8 8 0 0 0-8-8a8 8 0 0 0-8 8a8 8 0 0 0 8 8m0-18a10 10 0 0 1 10 10a10 10 0 0 1-10 10C6.47 22 2 17.5 2 12A10 10 0 0 1 12 2m.5 5v5.25l4.5 2.67l-.75 1.23L11 13V7z" />
</svg>

After

Width:  |  Height:  |  Size: 281 B

View File

@@ -43,6 +43,7 @@ import ChevronUpIcon from './ic-chevron-up.svg'
import CircleIcon from './circle-55.svg' import CircleIcon from './circle-55.svg'
import ClearCircleFilledIcon from './ic-clear-circle-filled.svg' import ClearCircleFilledIcon from './ic-clear-circle-filled.svg'
import CloseCircleFilledIcon from './ic-close-circle-filled.svg' import CloseCircleFilledIcon from './ic-close-circle-filled.svg'
import ClockIcon from './ic-clock.svg'
import CloseIcon from './ic-close.svg' import CloseIcon from './ic-close.svg'
import CloudOffIcon from './ic-cloud-off.svg' import CloudOffIcon from './ic-cloud-off.svg'
import CodeIcon from './ic-code.svg' import CodeIcon from './ic-code.svg'
@@ -263,6 +264,7 @@ export {
CircleIcon, CircleIcon,
ClearCircleFilledIcon, ClearCircleFilledIcon,
CloseCircleFilledIcon, CloseCircleFilledIcon,
ClockIcon,
CloseIcon, CloseIcon,
CloudOffIcon, CloudOffIcon,
CodeIcon, CodeIcon,

View File

@@ -37,6 +37,7 @@ export type IconType =
| 'chevron-right' | 'chevron-right'
| 'chevron-up' | 'chevron-up'
| 'clear-circle-filled' | 'clear-circle-filled'
| 'clock'
| 'close' | 'close'
| 'cloud-off' | 'cloud-off'
| 'code-2' | 'code-2'

View File

@@ -1,6 +1,6 @@
export enum WebAppEvent { export enum WebAppEvent {
NewUpdateAvailable = 'NewUpdateAvailable', NewUpdateAvailable = 'NewUpdateAvailable',
EditorFocused = 'EditorFocused', EditorDidFocus = 'EditorDidFocus',
BeganBackupDownload = 'BeganBackupDownload', BeganBackupDownload = 'BeganBackupDownload',
EndedBackupDownload = 'EndedBackupDownload', EndedBackupDownload = 'EndedBackupDownload',
PanelResized = 'PanelResized', PanelResized = 'PanelResized',

View File

@@ -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. * all data to see if user has an items key, and if not, only then create a new one.
*/ */
DownloadFirst = 'DownloadFirst', DownloadFirst = 'DownloadFirst',
LocalOnly = 'LocalOnly',
} }

View File

@@ -896,6 +896,12 @@ export class SyncService
const { items, beginDate, frozenDirtyIndex, neverSyncedDeleted } = await this.prepareForSync(options) 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() const inTimeResolveQueue = this.getPendingRequestsMadeInTimeToPiggyBackOnCurrentRequest()
if (!shouldExecuteSync) { if (!shouldExecuteSync) {

View File

@@ -11,6 +11,7 @@ export class VisibilityObserver {
*/ */
document.addEventListener('visibilitychange', this.onVisibilityChange) document.addEventListener('visibilitychange', this.onVisibilityChange)
window.addEventListener('focus', this.onFocusEvent, false) window.addEventListener('focus', this.onFocusEvent, false)
window.addEventListener('blur', this.onBlurEvent, false)
} }
onVisibilityChange = () => { onVisibilityChange = () => {
@@ -23,6 +24,10 @@ export class VisibilityObserver {
this.notifyEvent(WebAppEvent.WindowDidFocus) this.notifyEvent(WebAppEvent.WindowDidFocus)
} }
onBlurEvent = () => {
this.notifyEvent(WebAppEvent.WindowDidBlur)
}
private notifyEvent(event: WebAppEvent): void { private notifyEvent(event: WebAppEvent): void {
if (this.raceTimeout) { if (this.raceTimeout) {
clearTimeout(this.raceTimeout) clearTimeout(this.raceTimeout)
@@ -35,6 +40,7 @@ export class VisibilityObserver {
deinit(): void { deinit(): void {
document.removeEventListener('visibilitychange', this.onVisibilityChange) document.removeEventListener('visibilitychange', this.onVisibilityChange)
window.removeEventListener('focus', this.onFocusEvent) window.removeEventListener('focus', this.onFocusEvent)
window.removeEventListener('blur', this.onBlurEvent)
;(this.onEvent as unknown) = undefined ;(this.onEvent as unknown) = undefined
} }
} }

View File

@@ -168,7 +168,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
useEffect(() => { useEffect(() => {
const removeObserver = application.addWebEventObserver(async (eventName) => { const removeObserver = application.addWebEventObserver(async (eventName) => {
if (eventName === WebAppEvent.WindowDidFocus) { if (eventName === WebAppEvent.WindowDidFocus || eventName === WebAppEvent.WindowDidBlur) {
if (!(await application.protections.isLocked())) { if (!(await application.protections.isLocked())) {
application.sync.sync().catch(console.error) application.sync.sync().catch(console.error)
} }

View File

@@ -11,10 +11,11 @@ import { NoteType, noteTypeForEditorIdentifier } from '@standardnotes/snjs'
type Props = { type Props = {
noteViewController?: NoteViewController noteViewController?: NoteViewController
onClick?: () => void
onClickPreprocessing?: () => Promise<void> onClickPreprocessing?: () => Promise<void>
} }
const ChangeEditorButton: FunctionComponent<Props> = ({ noteViewController, onClickPreprocessing }: Props) => { const ChangeEditorButton: FunctionComponent<Props> = ({ noteViewController, onClick, onClickPreprocessing }: Props) => {
const application = useApplication() const application = useApplication()
const note = application.notesController.firstSelectedNote const note = application.notesController.firstSelectedNote
@@ -48,7 +49,10 @@ const ChangeEditorButton: FunctionComponent<Props> = ({ noteViewController, onCl
await onClickPreprocessing() await onClickPreprocessing()
} }
setIsOpen(willMenuOpen) setIsOpen(willMenuOpen)
}, [onClickPreprocessing, isOpen]) if (onClick) {
onClick()
}
}, [isOpen, onClickPreprocessing, onClick])
useEffect(() => { useEffect(() => {
return application.keyboardService.addCommandHandler({ return application.keyboardService.addCommandHandler({

View File

@@ -70,7 +70,7 @@ class Footer extends AbstractComponent<Props, State> {
case WebAppEvent.NewUpdateAvailable: case WebAppEvent.NewUpdateAvailable:
this.onNewUpdateAvailable() this.onNewUpdateAvailable()
break break
case WebAppEvent.EditorFocused: case WebAppEvent.EditorDidFocus:
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((data as any).eventSource === EditorEventSource.UserInteraction) { if ((data as any).eventSource === EditorEventSource.UserInteraction) {
this.closeAccountMenu() this.closeAccountMenu()

View File

@@ -84,6 +84,7 @@ export const IconNameToSvgMapping = {
bold: icons.BoldIcon, bold: icons.BoldIcon,
camera: icons.CameraIcon, camera: icons.CameraIcon,
check: icons.CheckIcon, check: icons.CheckIcon,
clock: icons.ClockIcon,
close: icons.CloseIcon, close: icons.CloseIcon,
code: icons.CodeIcon, code: icons.CodeIcon,
comment: icons.FeedbackIcon, comment: icons.FeedbackIcon,

View File

@@ -7,10 +7,11 @@ import LinkedItemsPanel from './LinkedItemsPanel'
type Props = { type Props = {
linkingController: LinkingController linkingController: LinkingController
onClick?: () => void
onClickPreprocessing?: () => Promise<void> onClickPreprocessing?: () => Promise<void>
} }
const LinkedItemsButton = ({ linkingController, onClickPreprocessing }: Props) => { const LinkedItemsButton = ({ linkingController, onClick, onClickPreprocessing }: Props) => {
const { activeItem, isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController const { activeItem, isLinkingPanelOpen, setIsLinkingPanelOpen } = linkingController
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
@@ -20,7 +21,10 @@ const LinkedItemsButton = ({ linkingController, onClickPreprocessing }: Props) =
await onClickPreprocessing() await onClickPreprocessing()
} }
setIsLinkingPanelOpen(willMenuOpen) setIsLinkingPanelOpen(willMenuOpen)
}, [isLinkingPanelOpen, onClickPreprocessing, setIsLinkingPanelOpen]) if (onClick) {
onClick()
}
}, [isLinkingPanelOpen, onClick, onClickPreprocessing, setIsLinkingPanelOpen])
if (!activeItem) { if (!activeItem) {
return null return null

View File

@@ -1,5 +1,8 @@
import { MILLISECONDS_IN_A_SECOND } from '@/Constants/Constants'
export const EditorSaveTimeoutDebounce = { export const EditorSaveTimeoutDebounce = {
Desktop: 350, Desktop: 350,
ImmediateChange: 100, ImmediateChange: 100,
NativeMobileWeb: 700, NativeMobileWeb: 700,
LargeNote: 60 * MILLISECONDS_IN_A_SECOND,
} }

View File

@@ -104,6 +104,9 @@ export class ItemGroupController {
controller: NoteViewController | FileViewController, controller: NoteViewController | FileViewController,
{ notify = true }: { notify: boolean } = { notify: true }, { notify = true }: { notify: boolean } = { notify: true },
): void { ): void {
if (controller instanceof NoteViewController) {
controller.syncOnlyIfLargeNote()
}
controller.deinit() controller.deinit()
removeFromArray(this.itemControllers, controller) removeFromArray(this.itemControllers, controller)

View File

@@ -28,6 +28,9 @@ describe('note view controller', () => {
createTemplateItem: jest.fn().mockReturnValue({} as SNNote), createTemplateItem: jest.fn().mockReturnValue({} as SNNote),
} as unknown as jest.Mocked<ItemManagerInterface>, } as unknown as jest.Mocked<ItemManagerInterface>,
mutator: {} as jest.Mocked<MutatorClientInterface>, mutator: {} as jest.Mocked<MutatorClientInterface>,
sessions: {
isSignedIn: jest.fn().mockReturnValue(true),
},
} as unknown as jest.Mocked<WebApplication> } as unknown as jest.Mocked<WebApplication>
application.isNativeMobileWeb = jest.fn().mockReturnValue(false) application.isNativeMobileWeb = jest.fn().mockReturnValue(false)

View File

@@ -25,6 +25,7 @@ import { TemplateNoteViewControllerOptions } from './TemplateNoteViewControllerO
import { log, LoggingDomain } from '@/Logging' import { log, LoggingDomain } from '@/Logging'
import { NoteSaveFunctionParams, NoteSyncController } from '../../../Controllers/NoteSyncController' import { NoteSaveFunctionParams, NoteSyncController } from '../../../Controllers/NoteSyncController'
import { IsNativeMobileWeb } from '@standardnotes/ui-services' import { IsNativeMobileWeb } from '@standardnotes/ui-services'
import { NoteStatus } from '../NoteStatusIndicator'
export type EditorValues = { export type EditorValues = {
title: string title: string
@@ -219,4 +220,28 @@ export class NoteViewController implements ItemViewControllerInterface {
await this.syncController.saveAndAwaitLocalPropagation(params) 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 { classNames } from '@standardnotes/utils'
import { ReactNode, useCallback, useState } from 'react' import { ReactNode, useCallback, useRef, useState } from 'react'
import { IconType, PrefKey, PrefDefaults } from '@standardnotes/snjs' import { IconType, PrefKey, PrefDefaults, SNNote } from '@standardnotes/snjs'
import Icon from '../Icon/Icon' import Icon from '../Icon/Icon'
import { useApplication } from '../ApplicationProvider' 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 = { export type NoteStatus = {
type: 'saving' | 'saved' | 'error' type: 'saving' | 'saved' | 'error' | 'waiting'
message: string message: string
desc?: string description?: ReactNode
} }
const IndicatorWithTooltip = ({ const IndicatorWithTooltip = ({
className, className,
onClick, onClick,
onBlur,
icon, icon,
isTooltipVisible, isTooltipVisible,
setIsTooltipVisible,
children, children,
animateIcon = false, animateIcon = false,
}: { }: {
className: string className: string
onClick: () => void onClick: () => void
onBlur: () => void
icon: IconType icon: IconType
isTooltipVisible: boolean isTooltipVisible: boolean
setIsTooltipVisible: React.Dispatch<React.SetStateAction<boolean>>
children: ReactNode children: ReactNode
animateIcon?: boolean animateIcon?: boolean
}) => ( }) => {
<div className="note-status-tooltip-container relative"> const buttonRef = useRef<HTMLButtonElement>(null)
<button
className={classNames('peer flex h-5 w-5 items-center justify-center rounded-full', className)} return (
onClick={onClick} <div className="note-status-tooltip-container">
onBlur={onBlur} <button
aria-describedby={ElementIds.NoteStatusTooltip} className={classNames('peer flex h-5 w-5 cursor-pointer items-center justify-center rounded-full', className)}
> onClick={onClick}
<Icon className={animateIcon ? 'animate-spin' : ''} type={icon} size="small" /> ref={buttonRef}
<span className="sr-only">Note sync status</span> >
</button> <Icon className={animateIcon ? 'animate-spin' : ''} type={icon} size="small" />
<div <VisuallyHidden>Note sync status</VisuallyHidden>
id={ElementIds.NoteStatusTooltip} </button>
className={classNames( <Popover
isTooltipVisible ? '' : 'hidden', title="Note sync status"
'absolute right-0 top-full min-w-[90vw] translate-x-2 translate-y-1 select-none rounded border border-border', open={isTooltipVisible}
'bg-default px-3 py-1.5 text-left peer-hover:block peer-focus:block md:min-w-max', togglePopover={() => setIsTooltipVisible((visible) => !visible)}
)} className="px-3 py-2"
> containerClassName="!min-w-0 !w-auto max-w-[90vw]"
{children} anchorElement={buttonRef}
side="bottom"
align="center"
offset={6}
disableMobileFullscreenTakeover
disableApplyingMobileWidth
>
{children}
</Popover>
</div> </div>
</div> )
) }
type Props = { type Props = {
note: SNNote
status: NoteStatus | undefined status: NoteStatus | undefined
syncTakingTooLong: boolean syncTakingTooLong: boolean
updateSavingIndicator?: boolean updateSavingIndicator?: boolean
} }
const NoteStatusIndicator = ({ const NoteStatusIndicator = ({
note,
status, status,
syncTakingTooLong, syncTakingTooLong,
updateSavingIndicator = PrefDefaults[PrefKey.UpdateSavingStatusIndicator], updateSavingIndicator = PrefDefaults[PrefKey.UpdateSavingStatusIndicator],
@@ -65,7 +79,9 @@ const NoteStatusIndicator = ({
const application = useApplication() const application = useApplication()
const [isTooltipVisible, setIsTooltipVisible] = useState(false) const [isTooltipVisible, setIsTooltipVisible] = useState(false)
const onBlur = () => setIsTooltipVisible(false) const toggleTooltip = useCallback(() => {
setIsTooltipVisible((visible) => !visible)
}, [])
const toggleShowPreference = useCallback(() => { const toggleShowPreference = useCallback(() => {
void application.setPreference(PrefKey.UpdateSavingStatusIndicator, !updateSavingIndicator) void application.setPreference(PrefKey.UpdateSavingStatusIndicator, !updateSavingIndicator)
@@ -79,13 +95,13 @@ const NoteStatusIndicator = ({
return ( return (
<IndicatorWithTooltip <IndicatorWithTooltip
className="bg-danger text-danger-contrast" className="bg-danger text-danger-contrast"
onClick={toggleShowPreference} onClick={toggleTooltip}
onBlur={onBlur}
icon="warning" icon="warning"
isTooltipVisible={isTooltipVisible} isTooltipVisible={isTooltipVisible}
setIsTooltipVisible={setIsTooltipVisible}
> >
<div className="text-sm font-bold text-danger">{status.message}</div> <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> </IndicatorWithTooltip>
) )
} }
@@ -94,15 +110,15 @@ const NoteStatusIndicator = ({
return ( return (
<IndicatorWithTooltip <IndicatorWithTooltip
className="bg-warning text-warning-contrast" className="bg-warning text-warning-contrast"
onClick={toggleShowPreference} onClick={toggleTooltip}
onBlur={onBlur}
icon={status && status.type === 'saving' ? 'sync' : 'warning'} icon={status && status.type === 'saving' ? 'sync' : 'warning'}
isTooltipVisible={isTooltipVisible} isTooltipVisible={isTooltipVisible}
setIsTooltipVisible={setIsTooltipVisible}
> >
{status ? ( {status ? (
<> <>
<div className="text-sm font-bold text-warning">{status.message}</div> <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> <div className="text-sm font-bold text-warning">Sync taking too long</div>
@@ -117,15 +133,35 @@ const NoteStatusIndicator = ({
className={classNames( className={classNames(
status.type === 'saving' && 'bg-contrast', status.type === 'saving' && 'bg-contrast',
status.type === 'saved' && 'bg-success text-success-contrast', status.type === 'saved' && 'bg-success text-success-contrast',
status.type === 'waiting' && 'bg-warning text-warning-contrast',
)} )}
onClick={toggleShowPreference} onClick={toggleTooltip}
onBlur={onBlur} icon={status.type === 'saving' ? 'sync' : status.type === 'waiting' ? 'clock' : 'check'}
icon={status.type === 'saving' ? 'sync' : 'check'}
animateIcon={status.type === 'saving'} animateIcon={status.type === 'saving'}
isTooltipVisible={isTooltipVisible} isTooltipVisible={isTooltipVisible}
setIsTooltipVisible={setIsTooltipVisible}
> >
<div className="text-sm font-bold">{status.message}</div> <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> </IndicatorWithTooltip>
) )
} }
@@ -133,15 +169,17 @@ const NoteStatusIndicator = ({
return ( return (
<IndicatorWithTooltip <IndicatorWithTooltip
className="bg-contrast text-passive-1" className="bg-contrast text-passive-1"
onClick={toggleShowPreference} onClick={toggleTooltip}
onBlur={onBlur}
icon="info" icon="info"
isTooltipVisible={isTooltipVisible} isTooltipVisible={isTooltipVisible}
setIsTooltipVisible={setIsTooltipVisible}
> >
<div className="text-sm font-bold">Note status updates are disabled</div> <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> </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 NoteConflictResolutionModal from './NoteConflictResolutionModal/NoteConflictResolutionModal'
import Icon from '../Icon/Icon' import Icon from '../Icon/Icon'
const MinimumStatusDuration = 400
function sortAlphabetically(array: ComponentInterface[]): ComponentInterface[] { function sortAlphabetically(array: ComponentInterface[]): ComponentInterface[] {
return array.sort((a, b) => (a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1)) return array.sort((a, b) => (a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1))
} }
@@ -83,6 +81,7 @@ type State = {
updateSavingIndicator?: boolean updateSavingIndicator?: boolean
editorFeatureIdentifier?: string editorFeatureIdentifier?: string
noteType?: NoteType noteType?: NoteType
focusModeEnabled?: boolean
conflictedNotes: SNNote[] conflictedNotes: SNNote[]
showConflictResolutionModal: boolean showConflictResolutionModal: boolean
@@ -91,7 +90,6 @@ type State = {
class NoteView extends AbstractComponent<NoteViewProps, State> { class NoteView extends AbstractComponent<NoteViewProps, State> {
readonly controller!: NoteViewController readonly controller!: NoteViewController
private statusTimeout?: NodeJS.Timeout
onEditorComponentLoad?: () => void onEditorComponentLoad?: () => void
private removeTrashKeyObserver?: () => void private removeTrashKeyObserver?: () => void
@@ -167,8 +165,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
;(this.ensureNoteIsInsertedBeforeUIAction as unknown) = undefined ;(this.ensureNoteIsInsertedBeforeUIAction as unknown) = undefined
this.onEditorComponentLoad = undefined this.onEditorComponentLoad = undefined
this.statusTimeout = undefined
;(this.onPanelResizeFinish as unknown) = undefined ;(this.onPanelResizeFinish as unknown) = undefined
;(this.authorizeAndDismissProtectedWarning as unknown) = undefined ;(this.authorizeAndDismissProtectedWarning as unknown) = undefined
;(this.editorComponentViewerRequestsReload as unknown) = undefined ;(this.editorComponentViewerRequestsReload as unknown) = undefined
@@ -235,9 +231,22 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
}) })
this.autorun(() => { this.autorun(() => {
const syncStatus = this.controller.syncStatus
const isFocusModeEnabled = this.application.paneController.focusModeEnabled
const didFocusModeChange = this.state.focusModeEnabled !== isFocusModeEnabled
this.setState({ this.setState({
showProtectedWarning: this.application.notesController.showProtectedWarning, 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) this.reloadEditorComponent().catch(console.error)
@@ -327,16 +336,18 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
} }
if (note.lastSyncBegan || note.dirty) { if (note.lastSyncBegan || note.dirty) {
const currentStatus = this.controller.syncStatus
const isWaitingToSyncLargeNote = currentStatus?.type === 'waiting'
if (note.lastSyncEnd) { 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() const shouldShowSavedStatus = note.lastSyncBegan && note.lastSyncEnd.getTime() > note.lastSyncBegan.getTime()
if (note.dirty || shouldShowSavingStatus) { if (hasStartedNewSync) {
this.showSavingStatus() this.controller.showSavingStatus()
} else if (this.state.noteStatus && shouldShowSavedStatus) { } else if (this.state.noteStatus && shouldShowSavedStatus && !isWaitingToSyncLargeNote) {
this.showAllChangesSavedStatus() this.controller.showAllChangesSavedStatus()
} }
} else { } else if (note.lastSyncBegan) {
this.showSavingStatus() this.controller.showSavingStatus()
} }
} }
} }
@@ -372,7 +383,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
const isInErrorState = this.state.saveError const isInErrorState = this.state.saveError
/** if we're still dirty, don't change status, a sync is likely upcoming. */ /** if we're still dirty, don't change status, a sync is likely upcoming. */
if (!this.note.dirty && isInErrorState) { if (!this.note.dirty && isInErrorState) {
this.showAllChangesSavedStatus() this.controller.showAllChangesSavedStatus()
} }
break break
} }
@@ -383,14 +394,14 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
* and we don't want to display an error here. * and we don't want to display an error here.
*/ */
if (this.note.dirty) { if (this.note.dirty) {
this.showErrorStatus() this.controller.showErrorSyncStatus()
} }
break break
case ApplicationEvent.LocalDatabaseWriteError: case ApplicationEvent.LocalDatabaseWriteError:
this.showErrorStatus({ this.controller.showErrorSyncStatus({
type: 'error', type: 'error',
message: 'Offline Saving Issue', message: 'Offline Saving Issue',
desc: 'Changes not saved', description: 'Changes not saved',
}) })
break break
case ApplicationEvent.UnprotectedSessionBegan: { case ApplicationEvent.UnprotectedSessionBegan: {
@@ -551,59 +562,6 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
return this.application.actions.extensionsInContextOfItem(this.note).length > 0 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 }) => { onTitleEnter: KeyboardEventHandler<HTMLInputElement> = ({ key, currentTarget }) => {
if (key !== KeyboardKey.Enter) { if (key !== KeyboardKey.Enter) {
return return
@@ -839,6 +797,10 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
})) }))
} }
triggerSyncOnAction = () => {
this.controller.syncNow()
}
override render() { override render() {
if (this.controller.dealloced) { if (this.controller.dealloced) {
return null return null
@@ -923,6 +885,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
/> />
</div> </div>
<NoteStatusIndicator <NoteStatusIndicator
note={this.note}
status={this.state.noteStatus} status={this.state.noteStatus}
syncTakingTooLong={this.state.syncTakingTooLong} syncTakingTooLong={this.state.syncTakingTooLong}
updateSavingIndicator={this.state.updateSavingIndicator} updateSavingIndicator={this.state.updateSavingIndicator}
@@ -930,6 +893,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
</div> </div>
{shouldShowConflictsButton && ( {shouldShowConflictsButton && (
<Button <Button
id={ElementIds.ConflictResolutionButton}
className="flex items-center" className="flex items-center"
primary primary
colorStyle="warning" colorStyle="warning"
@@ -946,10 +910,12 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
<> <>
<LinkedItemsButton <LinkedItemsButton
linkingController={this.application.linkingController} linkingController={this.application.linkingController}
onClick={this.triggerSyncOnAction}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction} onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/> />
<ChangeEditorButton <ChangeEditorButton
noteViewController={this.controller} noteViewController={this.controller}
onClick={this.triggerSyncOnAction}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction} onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/> />
<PinNoteButton <PinNoteButton
@@ -960,6 +926,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
)} )}
<NotesOptionsPanel <NotesOptionsPanel
notesController={this.application.notesController} notesController={this.application.notesController}
onClick={this.triggerSyncOnAction}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction} onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
onButtonBlur={() => { onButtonBlur={() => {
this.setState({ this.setState({

View File

@@ -122,23 +122,20 @@ export const PlainEditor = forwardRef<PlainEditorInterface, Props>(
needsAdjustMobileCursor.current = true needsAdjustMobileCursor.current = true
} }
if (lastEditorFocusEventSource.current) { application.notifyWebEvent(WebAppEvent.EditorDidFocus, { eventSource: lastEditorFocusEventSource.current })
application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: lastEditorFocusEventSource })
}
lastEditorFocusEventSource.current = undefined lastEditorFocusEventSource.current = undefined
onFocus() onFocus()
}, [application, isAdjustingMobileCursor, lastEditorFocusEventSource, onFocus]) }, [application, isAdjustingMobileCursor, lastEditorFocusEventSource, onFocus])
const onContentBlur = useCallback( const onContentBlur = useCallback(
(event: FocusEvent) => { (event: FocusEvent) => {
if (lastEditorFocusEventSource.current) {
application.notifyWebEvent(WebAppEvent.EditorFocused, { eventSource: lastEditorFocusEventSource })
}
lastEditorFocusEventSource.current = undefined lastEditorFocusEventSource.current = undefined
onBlur(event) onBlur(event)
}, },
[application, lastEditorFocusEventSource, onBlur], [lastEditorFocusEventSource, onBlur],
) )
const scrollMobileCursorIntoViewAfterWebviewResize = useCallback(() => { const scrollMobileCursorIntoViewAfterWebviewResize = useCallback(() => {

View File

@@ -4,6 +4,7 @@ import { formatDateForContextMenu } from '@/Utils/DateUtils'
import { calculateReadTime } from './Utils/calculateReadTime' import { calculateReadTime } from './Utils/calculateReadTime'
import { countNoteAttributes } from './Utils/countNoteAttributes' import { countNoteAttributes } from './Utils/countNoteAttributes'
import { WebApplicationInterface } from '@standardnotes/ui-services' import { WebApplicationInterface } from '@standardnotes/ui-services'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
export const useNoteAttributes = (application: WebApplicationInterface, note: SNNote) => { export const useNoteAttributes = (application: WebApplicationInterface, note: SNNote) => {
const { words, characters, paragraphs } = useMemo(() => countNoteAttributes(note.text), [note.text]) const { words, characters, paragraphs } = useMemo(() => countNoteAttributes(note.text), [note.text])
@@ -15,10 +16,13 @@ export const useNoteAttributes = (application: WebApplicationInterface, note: SN
const dateCreated = useMemo(() => formatDateForContextMenu(note.created_at), [note.created_at]) const dateCreated = useMemo(() => formatDateForContextMenu(note.created_at), [note.created_at])
const size = useMemo(() => new Blob([note.text]).size, [note.text])
const editor = application.componentManager.editorForNote(note) const editor = application.componentManager.editorForNote(note)
const format = editor.fileType const format = editor.fileType
return { return {
size,
words, words,
characters, characters,
paragraphs, paragraphs,
@@ -35,14 +39,16 @@ export const NoteAttributes: FunctionComponent<{
note: SNNote note: SNNote
className?: string className?: string
}> = ({ application, note, className }) => { }> = ({ application, note, className }) => {
const { words, characters, paragraphs, readTime, userModifiedDate, dateCreated, format } = useNoteAttributes( const { size, words, characters, paragraphs, readTime, userModifiedDate, dateCreated, format } = useNoteAttributes(
application, application,
note, note,
) )
const canShowWordCount = typeof words === 'number' && (format === 'txt' || format === 'md')
return ( return (
<div className={classNames('select-text px-3 py-1.5 text-sm font-medium text-neutral lg:text-xs', className)}> <div className={classNames('select-text px-3 py-1.5 text-sm font-medium text-neutral lg:text-xs', className)}>
{typeof words === 'number' && (format === 'txt' || format === 'md') ? ( {canShowWordCount ? (
<> <>
<div className="mb-1"> <div className="mb-1">
{words} words · {characters} characters · {paragraphs} paragraphs {words} words · {characters} characters · {paragraphs} paragraphs
@@ -58,9 +64,12 @@ export const NoteAttributes: FunctionComponent<{
<div className="mb-1"> <div className="mb-1">
<span className="font-semibold">Created:</span> {dateCreated} <span className="font-semibold">Created:</span> {dateCreated}
</div> </div>
<div> <div className="mb-1">
<span className="font-semibold">Note ID:</span> {note.uuid} <span className="font-semibold">Note ID:</span> {note.uuid}
</div> </div>
<div>
<span className="font-semibold">Size:</span> {formatSizeToReadableString(size)}
</div>
</div> </div>
) )
} }

View File

@@ -1,17 +1,15 @@
import Icon from '@/Components/Icon/Icon' import Icon from '@/Components/Icon/Icon'
import { FunctionComponent } from 'react' import { FunctionComponent } from 'react'
import { SNNote } from '@standardnotes/snjs' import { SNNote } from '@standardnotes/snjs'
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants/Constants' import { LargeNoteThreshold } from '@/Constants/Constants'
import HorizontalSeparator from '../Shared/HorizontalSeparator' import HorizontalSeparator from '../Shared/HorizontalSeparator'
export const NOTE_SIZE_WARNING_THRESHOLD = 0.5 * BYTES_IN_ONE_MEGABYTE
export const NoteSizeWarning: FunctionComponent<{ export const NoteSizeWarning: FunctionComponent<{
note: SNNote note: SNNote
}> = ({ note }) => { }> = ({ note }) => {
return new Blob([note.text]).size > NOTE_SIZE_WARNING_THRESHOLD ? ( return new Blob([note.text]).size > LargeNoteThreshold ? (
<> <>
<HorizontalSeparator classes="my-2" /> <HorizontalSeparator classes="mt-2" />
<div className="bg-warning-faded relative flex items-center px-3 py-3.5"> <div className="bg-warning-faded relative flex items-center px-3 py-3.5">
<Icon type="warning" className="mr-3 flex-shrink-0 text-accessory-tint-3" /> <Icon type="warning" className="mr-3 flex-shrink-0 text-accessory-tint-3" />
<div className="leading-140% max-w-80% select-none text-warning"> <div className="leading-140% max-w-80% select-none text-warning">

View File

@@ -9,11 +9,12 @@ import { ElementIds } from '@/Constants/ElementIDs'
type Props = { type Props = {
notesController: NotesController notesController: NotesController
onClick?: () => void
onClickPreprocessing?: () => Promise<void> onClickPreprocessing?: () => Promise<void>
onButtonBlur?: (event: FocusEvent) => void onButtonBlur?: (event: FocusEvent) => void
} }
const NotesOptionsPanel = ({ notesController, onClickPreprocessing, onButtonBlur }: Props) => { const NotesOptionsPanel = ({ notesController, onClick, onClickPreprocessing, onButtonBlur }: Props) => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
@@ -23,7 +24,10 @@ const NotesOptionsPanel = ({ notesController, onClickPreprocessing, onButtonBlur
await onClickPreprocessing() await onClickPreprocessing()
} }
setIsOpen(willMenuOpen) setIsOpen(willMenuOpen)
}, [onClickPreprocessing, isOpen]) if (onClick) {
onClick()
}
}, [isOpen, onClickPreprocessing, onClick])
const [disableClickOutside, setDisableClickOutside] = useState(false) const [disableClickOutside, setDisableClickOutside] = useState(false)
const handleDisableClickOutsideRequest = useCallback((disabled: boolean) => { const handleDisableClickOutsideRequest = useCallback((disabled: boolean) => {

View File

@@ -27,6 +27,7 @@ const PositionedPopoverContent = ({
disableClickOutside, disableClickOutside,
disableMobileFullscreenTakeover, disableMobileFullscreenTakeover,
disableFlip, disableFlip,
disableApplyingMobileWidth,
maxHeight, maxHeight,
portal = true, portal = true,
offset, offset,
@@ -57,6 +58,7 @@ const PositionedPopoverContent = ({
side, side,
disableMobileFullscreenTakeover, disableMobileFullscreenTakeover,
disableFlip, disableFlip,
disableApplyingMobileWidth,
maxHeightFunction: maxHeight, maxHeightFunction: maxHeight,
offset, offset,
}) })

View File

@@ -44,6 +44,7 @@ type CommonPopoverProps = {
togglePopover?: () => void togglePopover?: () => void
disableMobileFullscreenTakeover?: boolean disableMobileFullscreenTakeover?: boolean
disableFlip?: boolean disableFlip?: boolean
disableApplyingMobileWidth?: boolean
forceFullHeightOnMobile?: boolean forceFullHeightOnMobile?: boolean
title: string title: string
portal?: boolean portal?: boolean

View File

@@ -44,6 +44,8 @@ type BlocksEditorProps = {
spellcheck?: boolean spellcheck?: boolean
ignoreFirstChange?: boolean ignoreFirstChange?: boolean
readonly?: boolean readonly?: boolean
onFocus?: () => void
onBlur?: () => void
} }
export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
@@ -54,6 +56,8 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
spellcheck, spellcheck,
ignoreFirstChange = false, ignoreFirstChange = false,
readonly, readonly,
onFocus,
onBlur,
}) => { }) => {
const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false) const [didIgnoreFirstChange, setDidIgnoreFirstChange] = useState(false)
const handleChange = useCallback( const handleChange = useCallback(
@@ -95,6 +99,8 @@ export const BlocksEditor: FunctionComponent<BlocksEditorProps> = ({
className, className,
)} )}
spellCheck={spellcheck} spellCheck={spellcheck}
onFocus={onFocus}
onBlur={onBlur}
/> />
<div className="search-highlight-container pointer-events-none absolute left-0 top-0 h-full w-full" /> <div className="search-highlight-container pointer-events-none absolute left-0 top-0 h-full w-full" />
</div> </div>

View File

@@ -6,6 +6,7 @@ import {
FeatureStatus, FeatureStatus,
GetSuperNoteFeature, GetSuperNoteFeature,
EditorLineHeightValues, EditorLineHeightValues,
WebAppEvent,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { CSSProperties, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react' import { CSSProperties, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { BlocksEditor } from './BlocksEditor' import { BlocksEditor } from './BlocksEditor'
@@ -37,6 +38,7 @@ import NotEntitledBanner from '../ComponentView/NotEntitledBanner'
import AutoFocusPlugin from './Plugins/AutoFocusPlugin' import AutoFocusPlugin from './Plugins/AutoFocusPlugin'
import usePreference from '@/Hooks/usePreference' import usePreference from '@/Hooks/usePreference'
import BlockPickerMenuPlugin from './Plugins/BlockPickerPlugin/BlockPickerPlugin' import BlockPickerMenuPlugin from './Plugins/BlockPickerPlugin/BlockPickerPlugin'
import { EditorEventSource } from '@/Types/EditorEventSource'
export const SuperNotePreviewCharLimit = 160 export const SuperNotePreviewCharLimit = 160
@@ -179,6 +181,10 @@ export const SuperEditor: FunctionComponent<Props> = ({
} }
}, []) }, [])
const onFocus = useCallback(() => {
application.notifyWebEvent(WebAppEvent.EditorDidFocus, { eventSource: EditorEventSource.UserInteraction })
}, [application])
return ( return (
<div <div
className="font-editor relative flex h-full w-full flex-col" className="font-editor relative flex h-full w-full flex-col"
@@ -203,6 +209,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
previewLength={SuperNotePreviewCharLimit} previewLength={SuperNotePreviewCharLimit}
spellcheck={spellcheck} spellcheck={spellcheck}
readonly={note.current.locked || readonly} readonly={note.current.locked || readonly}
onFocus={onFocus}
> >
<ItemSelectionPlugin currentNote={note.current} /> <ItemSelectionPlugin currentNote={note.current} />
<FilePlugin currentNote={note.current} /> <FilePlugin currentNote={note.current} />

View File

@@ -15,6 +15,7 @@ export const MAX_MENU_SIZE_MULTIPLIER = 30
export const FOCUSABLE_BUT_NOT_TABBABLE = -1 export const FOCUSABLE_BUT_NOT_TABBABLE = -1
export const NOTES_LIST_SCROLL_THRESHOLD = 200 export const NOTES_LIST_SCROLL_THRESHOLD = 200
export const MILLISECONDS_IN_A_SECOND = 1000
export const MILLISECONDS_IN_A_DAY = 1000 * 60 * 60 * 24 export const MILLISECONDS_IN_A_DAY = 1000 * 60 * 60 * 24
export const DAYS_IN_A_WEEK = 7 export const DAYS_IN_A_WEEK = 7
export const DAYS_IN_A_YEAR = 365 export const DAYS_IN_A_YEAR = 365
@@ -58,3 +59,5 @@ export const SupportsPassiveListeners = (() => {
} }
return supportsPassive return supportsPassive
})() })()
export const LargeNoteThreshold = 1.5 * BYTES_IN_ONE_MEGABYTE

View File

@@ -13,4 +13,5 @@ export const ElementIds = {
NoteStatusTooltip: 'note-status-tooltip', NoteStatusTooltip: 'note-status-tooltip',
ItemLinkAutocompleteInput: 'item-link-autocomplete-input', ItemLinkAutocompleteInput: 'item-link-autocomplete-input',
SearchBar: 'search-bar', SearchBar: 'search-bar',
ConflictResolutionButton: 'conflict-resolution-button',
} as const } as const

View File

@@ -1,5 +1,5 @@
import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem' import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem'
import { destroyAllObjectProperties, isMobileScreen } from '@/Utils' import { debounce, destroyAllObjectProperties, isMobileScreen } from '@/Utils'
import { import {
ApplicationEvent, ApplicationEvent,
CollectionSort, CollectionSort,
@@ -216,7 +216,7 @@ export class ItemListController
eventBus.addEventHandler(this, ApplicationEvent.PreferencesChanged) eventBus.addEventHandler(this, ApplicationEvent.PreferencesChanged)
eventBus.addEventHandler(this, ApplicationEvent.SignedIn) eventBus.addEventHandler(this, ApplicationEvent.SignedIn)
eventBus.addEventHandler(this, ApplicationEvent.CompletedFullSync) eventBus.addEventHandler(this, ApplicationEvent.CompletedFullSync)
eventBus.addEventHandler(this, WebAppEvent.EditorFocused) eventBus.addEventHandler(this, WebAppEvent.EditorDidFocus)
this.disposers.push( this.disposers.push(
reaction( reaction(
@@ -276,9 +276,9 @@ export class ItemListController
), ),
) )
window.onresize = () => { window.onresize = debounce(() => {
this.resetPagination(true) this.resetPagination(true)
} }, 100)
} }
getPersistableValue = (): SelectionControllerPersistableValue => { getPersistableValue = (): SelectionControllerPersistableValue => {
@@ -325,7 +325,7 @@ export class ItemListController
break break
} }
case WebAppEvent.EditorFocused: { case WebAppEvent.EditorDidFocus: {
this.setShowDisplayOptionsMenu(false) this.setShowDisplayOptionsMenu(false)
break break
} }

View File

@@ -5,13 +5,18 @@ import {
ItemManagerInterface, ItemManagerInterface,
MutatorClientInterface, MutatorClientInterface,
SessionsClientInterface, SessionsClientInterface,
SyncMode,
SyncServiceInterface, SyncServiceInterface,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { Deferred } from '@standardnotes/utils' import { Deferred } from '@standardnotes/utils'
import { EditorSaveTimeoutDebounce } from '../Components/NoteView/Controller/EditorSaveTimeoutDebounce' import { EditorSaveTimeoutDebounce } from '../Components/NoteView/Controller/EditorSaveTimeoutDebounce'
import { IsNativeMobileWeb } from '@standardnotes/ui-services' import { IsNativeMobileWeb } from '@standardnotes/ui-services'
import { LargeNoteThreshold } from '@/Constants/Constants'
import { NoteStatus } from '@/Components/NoteView/NoteStatusIndicator'
import { action, makeObservable, observable, runInAction } from 'mobx'
const NotePreviewCharLimit = 160 const NotePreviewCharLimit = 160
const MinimumStatusChangeDuration = 400
export type NoteSaveFunctionParams = { export type NoteSaveFunctionParams = {
title?: string title?: string
@@ -22,13 +27,16 @@ export type NoteSaveFunctionParams = {
previews?: { previewPlain: string; previewHtml?: string } previews?: { previewPlain: string; previewHtml?: string }
customMutate?: (mutator: NoteMutator) => void customMutate?: (mutator: NoteMutator) => void
onLocalPropagationComplete?: () => void onLocalPropagationComplete?: () => void
onRemoteSyncComplete?: () => void
} }
export class NoteSyncController { export class NoteSyncController {
savingLocallyPromise: ReturnType<typeof Deferred<void>> | null = null savingLocallyPromise: ReturnType<typeof Deferred<void>> | null = null
private saveTimeout?: ReturnType<typeof setTimeout> private syncTimeout?: ReturnType<typeof setTimeout>
private largeNoteSyncTimeout?: ReturnType<typeof setTimeout>
private statusChangeTimeout?: ReturnType<typeof setTimeout>
status: NoteStatus | undefined = undefined
constructor( constructor(
private item: SNNote, private item: SNNote,
@@ -38,43 +46,123 @@ export class NoteSyncController {
private sync: SyncServiceInterface, private sync: SyncServiceInterface,
private alerts: AlertService, private alerts: AlertService,
private _isNativeMobileWeb: IsNativeMobileWeb, private _isNativeMobileWeb: IsNativeMobileWeb,
) {} ) {
makeObservable(this, {
status: observable,
setStatus: action,
})
}
setStatus(status: NoteStatus, wait = true) {
if (this.statusChangeTimeout) {
clearTimeout(this.statusChangeTimeout)
}
if (wait) {
this.statusChangeTimeout = setTimeout(() => {
runInAction(() => {
this.status = status
})
}, MinimumStatusChangeDuration)
} else {
this.status = status
}
}
showSavingStatus() {
this.setStatus(
{
type: 'saving',
message: 'Saving…',
},
false,
)
}
showAllChangesSavedStatus() {
this.setStatus({
type: 'saved',
message: 'All changes saved' + (this.sessions.isSignedOut() ? ' offline' : ''),
})
}
showWaitingToSyncLargeNoteStatus() {
this.setStatus(
{
type: 'waiting',
message: 'Note is too large',
description: 'It will be synced less often. Changes will be saved offline normally.',
},
false,
)
}
showErrorStatus(error?: NoteStatus) {
if (!error) {
error = {
type: 'error',
message: 'Sync Unreachable',
description: 'Changes saved offline',
}
}
this.setStatus(error)
}
setItem(item: SNNote) { setItem(item: SNNote) {
this.item = item this.item = item
} }
deinit() { deinit() {
if (this.saveTimeout) { if (this.syncTimeout) {
clearTimeout(this.saveTimeout) clearTimeout(this.syncTimeout)
}
if (this.largeNoteSyncTimeout) {
clearTimeout(this.largeNoteSyncTimeout)
}
if (this.statusChangeTimeout) {
clearTimeout(this.statusChangeTimeout)
} }
if (this.savingLocallyPromise) { if (this.savingLocallyPromise) {
this.savingLocallyPromise.reject() this.savingLocallyPromise.reject()
} }
this.savingLocallyPromise = null this.savingLocallyPromise = null
this.saveTimeout = undefined this.largeNoteSyncTimeout = undefined
this.syncTimeout = undefined
this.status = undefined
this.statusChangeTimeout = undefined
;(this.item as unknown) = undefined ;(this.item as unknown) = undefined
} }
private isLargeNote(text: string): boolean {
const textByteSize = new Blob([text]).size
return textByteSize > LargeNoteThreshold
}
public async saveAndAwaitLocalPropagation(params: NoteSaveFunctionParams): Promise<void> { public async saveAndAwaitLocalPropagation(params: NoteSaveFunctionParams): Promise<void> {
this.savingLocallyPromise = Deferred<void>() this.savingLocallyPromise = Deferred<void>()
if (this.saveTimeout) { if (this.syncTimeout) {
clearTimeout(this.saveTimeout) clearTimeout(this.syncTimeout)
} }
const noDebounce = params.bypassDebouncer || this.sessions.isSignedOut() const noDebounce = params.bypassDebouncer || this.sessions.isSignedOut()
const syncDebounceMs = noDebounce
const syncDebouceMs = noDebounce
? EditorSaveTimeoutDebounce.ImmediateChange ? EditorSaveTimeoutDebounce.ImmediateChange
: this._isNativeMobileWeb.execute().getValue() : this._isNativeMobileWeb.execute().getValue()
? EditorSaveTimeoutDebounce.NativeMobileWeb ? EditorSaveTimeoutDebounce.NativeMobileWeb
: EditorSaveTimeoutDebounce.Desktop : EditorSaveTimeoutDebounce.Desktop
return new Promise((resolve) => { return new Promise((resolve) => {
this.saveTimeout = setTimeout(() => { const isLargeNote = this.isLargeNote(params.text ? params.text : this.item.text)
void this.undebouncedSave({
if (isLargeNote) {
this.showWaitingToSyncLargeNoteStatus()
this.queueLargeNoteSyncIfNeeded()
}
this.syncTimeout = setTimeout(() => {
void this.undebouncedMutateAndSync({
...params, ...params,
localOnly: isLargeNote,
onLocalPropagationComplete: () => { onLocalPropagationComplete: () => {
if (this.savingLocallyPromise) { if (this.savingLocallyPromise) {
this.savingLocallyPromise.resolve() this.savingLocallyPromise.resolve()
@@ -82,11 +170,34 @@ export class NoteSyncController {
resolve() resolve()
}, },
}) })
}, syncDebouceMs) }, syncDebounceMs)
}) })
} }
private async undebouncedSave(params: NoteSaveFunctionParams): Promise<void> { private queueLargeNoteSyncIfNeeded(): void {
const isAlreadyAQueuedLargeNoteSync = this.largeNoteSyncTimeout !== undefined
if (!isAlreadyAQueuedLargeNoteSync) {
const isSignedIn = this.sessions.isSignedIn()
const timeout = isSignedIn ? EditorSaveTimeoutDebounce.LargeNote : EditorSaveTimeoutDebounce.ImmediateChange
this.largeNoteSyncTimeout = setTimeout(() => {
this.largeNoteSyncTimeout = undefined
void this.performSyncOfLargeItem()
}, timeout)
}
}
private async performSyncOfLargeItem(): Promise<void> {
const item = this.items.findItem(this.item.uuid)
if (!item || !item.dirty) {
return
}
void this.sync.sync()
}
private async undebouncedMutateAndSync(params: NoteSaveFunctionParams & { localOnly: boolean }): Promise<void> {
if (!this.items.findItem(this.item.uuid)) { if (!this.items.findItem(this.item.uuid)) {
void this.alerts.alert(InfoStrings.InvalidNote) void this.alerts.alert(InfoStrings.InvalidNote)
return return
@@ -123,10 +234,17 @@ export class NoteSyncController {
params.isUserModified ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps, params.isUserModified ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps,
) )
void this.sync.sync().then(() => { void this.sync.sync({ mode: params.localOnly ? SyncMode.LocalOnly : undefined })
params.onRemoteSyncComplete?.()
}) this.queueLargeNoteSyncIfNeeded()
params.onLocalPropagationComplete?.() params.onLocalPropagationComplete?.()
} }
public syncOnlyIfLargeNote(): void {
const isLargeNote = this.isLargeNote(this.item.text)
if (isLargeNote) {
void this.performSyncOfLargeItem()
}
}
} }

View File

@@ -0,0 +1,28 @@
import dayjs from 'dayjs'
import RelativeTimePlugin from 'dayjs/plugin/relativeTime'
import UpdateLocalePlugin from 'dayjs/plugin/updateLocale'
dayjs.extend(UpdateLocalePlugin)
dayjs.extend(RelativeTimePlugin)
dayjs.updateLocale('en', {
relativeTime: {
future: 'in %s',
past: '%s ago',
s: '%ds',
m: 'a minute',
mm: '%d minutes',
h: 'an hour',
hh: '%d hours',
d: 'a day',
dd: '%d days',
M: 'a month',
MM: '%d months',
y: 'a year',
yy: '%d years',
},
})
export function getRelativeTimeString(date: Parameters<typeof dayjs>[0]): string {
return dayjs(date).fromNow()
}

View File

@@ -49,13 +49,17 @@
} }
.note-view-options-buttons, .note-view-options-buttons,
.note-status-tooltip-container { .note-status-tooltip-container,
#conflict-resolution-button {
opacity: 0; opacity: 0;
} }
#editor-title-bar:hover .note-view-options-buttons, #editor-title-bar:hover {
#editor-title-bar:hover .note-status-tooltip-container { .note-view-options-buttons,
opacity: 1; .note-status-tooltip-container,
#conflict-resolution-button {
opacity: 1;
}
} }
} }