diff --git a/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx b/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx
index b7f2d6054..f38e6a856 100644
--- a/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx
+++ b/packages/web/src/javascripts/Components/SuperEditor/SuperEditor.tsx
@@ -6,6 +6,7 @@ import {
FeatureStatus,
GetSuperNoteFeature,
EditorLineHeightValues,
+ WebAppEvent,
} from '@standardnotes/snjs'
import { CSSProperties, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { BlocksEditor } from './BlocksEditor'
@@ -37,6 +38,7 @@ import NotEntitledBanner from '../ComponentView/NotEntitledBanner'
import AutoFocusPlugin from './Plugins/AutoFocusPlugin'
import usePreference from '@/Hooks/usePreference'
import BlockPickerMenuPlugin from './Plugins/BlockPickerPlugin/BlockPickerPlugin'
+import { EditorEventSource } from '@/Types/EditorEventSource'
export const SuperNotePreviewCharLimit = 160
@@ -179,6 +181,10 @@ export const SuperEditor: FunctionComponent
= ({
}
}, [])
+ const onFocus = useCallback(() => {
+ application.notifyWebEvent(WebAppEvent.EditorDidFocus, { eventSource: EditorEventSource.UserInteraction })
+ }, [application])
+
return (
= ({
previewLength={SuperNotePreviewCharLimit}
spellcheck={spellcheck}
readonly={note.current.locked || readonly}
+ onFocus={onFocus}
>
diff --git a/packages/web/src/javascripts/Constants/Constants.ts b/packages/web/src/javascripts/Constants/Constants.ts
index f50c75993..fbbe3af09 100644
--- a/packages/web/src/javascripts/Constants/Constants.ts
+++ b/packages/web/src/javascripts/Constants/Constants.ts
@@ -15,6 +15,7 @@ export const MAX_MENU_SIZE_MULTIPLIER = 30
export const FOCUSABLE_BUT_NOT_TABBABLE = -1
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 DAYS_IN_A_WEEK = 7
export const DAYS_IN_A_YEAR = 365
@@ -58,3 +59,5 @@ export const SupportsPassiveListeners = (() => {
}
return supportsPassive
})()
+
+export const LargeNoteThreshold = 1.5 * BYTES_IN_ONE_MEGABYTE
diff --git a/packages/web/src/javascripts/Constants/ElementIDs.ts b/packages/web/src/javascripts/Constants/ElementIDs.ts
index c0c77dee2..34c3f4065 100644
--- a/packages/web/src/javascripts/Constants/ElementIDs.ts
+++ b/packages/web/src/javascripts/Constants/ElementIDs.ts
@@ -13,4 +13,5 @@ export const ElementIds = {
NoteStatusTooltip: 'note-status-tooltip',
ItemLinkAutocompleteInput: 'item-link-autocomplete-input',
SearchBar: 'search-bar',
+ ConflictResolutionButton: 'conflict-resolution-button',
} as const
diff --git a/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts b/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts
index 31b7381aa..d5f4aeddb 100644
--- a/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts
+++ b/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts
@@ -1,5 +1,5 @@
import { ListableContentItem } from '@/Components/ContentListView/Types/ListableContentItem'
-import { destroyAllObjectProperties, isMobileScreen } from '@/Utils'
+import { debounce, destroyAllObjectProperties, isMobileScreen } from '@/Utils'
import {
ApplicationEvent,
CollectionSort,
@@ -216,7 +216,7 @@ export class ItemListController
eventBus.addEventHandler(this, ApplicationEvent.PreferencesChanged)
eventBus.addEventHandler(this, ApplicationEvent.SignedIn)
eventBus.addEventHandler(this, ApplicationEvent.CompletedFullSync)
- eventBus.addEventHandler(this, WebAppEvent.EditorFocused)
+ eventBus.addEventHandler(this, WebAppEvent.EditorDidFocus)
this.disposers.push(
reaction(
@@ -276,9 +276,9 @@ export class ItemListController
),
)
- window.onresize = () => {
+ window.onresize = debounce(() => {
this.resetPagination(true)
- }
+ }, 100)
}
getPersistableValue = (): SelectionControllerPersistableValue => {
@@ -325,7 +325,7 @@ export class ItemListController
break
}
- case WebAppEvent.EditorFocused: {
+ case WebAppEvent.EditorDidFocus: {
this.setShowDisplayOptionsMenu(false)
break
}
diff --git a/packages/web/src/javascripts/Controllers/NoteSyncController.ts b/packages/web/src/javascripts/Controllers/NoteSyncController.ts
index 78c04e4cf..138e48b51 100644
--- a/packages/web/src/javascripts/Controllers/NoteSyncController.ts
+++ b/packages/web/src/javascripts/Controllers/NoteSyncController.ts
@@ -5,13 +5,18 @@ import {
ItemManagerInterface,
MutatorClientInterface,
SessionsClientInterface,
+ SyncMode,
SyncServiceInterface,
} from '@standardnotes/snjs'
import { Deferred } from '@standardnotes/utils'
import { EditorSaveTimeoutDebounce } from '../Components/NoteView/Controller/EditorSaveTimeoutDebounce'
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 MinimumStatusChangeDuration = 400
export type NoteSaveFunctionParams = {
title?: string
@@ -22,13 +27,16 @@ export type NoteSaveFunctionParams = {
previews?: { previewPlain: string; previewHtml?: string }
customMutate?: (mutator: NoteMutator) => void
onLocalPropagationComplete?: () => void
- onRemoteSyncComplete?: () => void
}
export class NoteSyncController {
savingLocallyPromise: ReturnType> | null = null
- private saveTimeout?: ReturnType
+ private syncTimeout?: ReturnType
+ private largeNoteSyncTimeout?: ReturnType
+ private statusChangeTimeout?: ReturnType
+
+ status: NoteStatus | undefined = undefined
constructor(
private item: SNNote,
@@ -38,43 +46,123 @@ export class NoteSyncController {
private sync: SyncServiceInterface,
private alerts: AlertService,
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) {
this.item = item
}
deinit() {
- if (this.saveTimeout) {
- clearTimeout(this.saveTimeout)
+ if (this.syncTimeout) {
+ clearTimeout(this.syncTimeout)
+ }
+ if (this.largeNoteSyncTimeout) {
+ clearTimeout(this.largeNoteSyncTimeout)
+ }
+ if (this.statusChangeTimeout) {
+ clearTimeout(this.statusChangeTimeout)
}
if (this.savingLocallyPromise) {
this.savingLocallyPromise.reject()
}
this.savingLocallyPromise = null
- this.saveTimeout = undefined
+ this.largeNoteSyncTimeout = undefined
+ this.syncTimeout = undefined
+ this.status = undefined
+ this.statusChangeTimeout = 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 {
this.savingLocallyPromise = Deferred()
- if (this.saveTimeout) {
- clearTimeout(this.saveTimeout)
+ if (this.syncTimeout) {
+ clearTimeout(this.syncTimeout)
}
const noDebounce = params.bypassDebouncer || this.sessions.isSignedOut()
-
- const syncDebouceMs = noDebounce
+ const syncDebounceMs = noDebounce
? EditorSaveTimeoutDebounce.ImmediateChange
: this._isNativeMobileWeb.execute().getValue()
? EditorSaveTimeoutDebounce.NativeMobileWeb
: EditorSaveTimeoutDebounce.Desktop
return new Promise((resolve) => {
- this.saveTimeout = setTimeout(() => {
- void this.undebouncedSave({
+ const isLargeNote = this.isLargeNote(params.text ? params.text : this.item.text)
+
+ if (isLargeNote) {
+ this.showWaitingToSyncLargeNoteStatus()
+ this.queueLargeNoteSyncIfNeeded()
+ }
+
+ this.syncTimeout = setTimeout(() => {
+ void this.undebouncedMutateAndSync({
...params,
+ localOnly: isLargeNote,
onLocalPropagationComplete: () => {
if (this.savingLocallyPromise) {
this.savingLocallyPromise.resolve()
@@ -82,11 +170,34 @@ export class NoteSyncController {
resolve()
},
})
- }, syncDebouceMs)
+ }, syncDebounceMs)
})
}
- private async undebouncedSave(params: NoteSaveFunctionParams): Promise {
+ 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 {
+ 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 {
if (!this.items.findItem(this.item.uuid)) {
void this.alerts.alert(InfoStrings.InvalidNote)
return
@@ -123,10 +234,17 @@ export class NoteSyncController {
params.isUserModified ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps,
)
- void this.sync.sync().then(() => {
- params.onRemoteSyncComplete?.()
- })
+ void this.sync.sync({ mode: params.localOnly ? SyncMode.LocalOnly : undefined })
+
+ this.queueLargeNoteSyncIfNeeded()
params.onLocalPropagationComplete?.()
}
+
+ public syncOnlyIfLargeNote(): void {
+ const isLargeNote = this.isLargeNote(this.item.text)
+ if (isLargeNote) {
+ void this.performSyncOfLargeItem()
+ }
+ }
}
diff --git a/packages/web/src/javascripts/Utils/GetRelativeTimeString.ts b/packages/web/src/javascripts/Utils/GetRelativeTimeString.ts
new file mode 100644
index 000000000..5b2cbfb05
--- /dev/null
+++ b/packages/web/src/javascripts/Utils/GetRelativeTimeString.ts
@@ -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[0]): string {
+ return dayjs(date).fromNow()
+}
diff --git a/packages/web/src/stylesheets/_focused.scss b/packages/web/src/stylesheets/_focused.scss
index f1339ef16..aed06d282 100644
--- a/packages/web/src/stylesheets/_focused.scss
+++ b/packages/web/src/stylesheets/_focused.scss
@@ -49,13 +49,17 @@
}
.note-view-options-buttons,
- .note-status-tooltip-container {
+ .note-status-tooltip-container,
+ #conflict-resolution-button {
opacity: 0;
}
- #editor-title-bar:hover .note-view-options-buttons,
- #editor-title-bar:hover .note-status-tooltip-container {
- opacity: 1;
+ #editor-title-bar:hover {
+ .note-view-options-buttons,
+ .note-status-tooltip-container,
+ #conflict-resolution-button {
+ opacity: 1;
+ }
}
}