From 4432f1cb4cfc2042ddfb7bd574d53270626ebe0d Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Thu, 20 Oct 2022 18:56:59 +0530 Subject: [PATCH] feat: persist selected tag & note locally (#1851) --- .../ContentListView/ContentList.tsx | 7 +- .../ContentListView/ContentListView.tsx | 4 +- .../javascripts/Components/Menu/MenuItem.tsx | 2 +- .../Preferences/Panes/General/General.tsx | 2 + .../Preferences/Panes/General/Persistence.tsx | 51 ++++++ .../QuickSettingsMenu/QuickSettingsMenu.tsx | 2 +- .../QuickSettingsMenu/ThemesMenuButton.tsx | 2 +- .../RadioIndicator.tsx | 0 .../Components/Radio/StyledRadioInput.tsx | 16 ++ .../RevisionHistoryModal/HistoryListItem.tsx | 2 +- .../Components/Tags/SmartViewsListItem.tsx | 4 +- .../Components/Tags/TagsListItem.tsx | 4 +- .../Controllers/Abstract/Persistable.ts | 4 + .../Abstract/PersistenceService.ts | 60 +++++++ .../Controllers/CrossControllerEvent.ts | 2 + .../ItemList/ItemListController.ts | 51 +++++- .../Navigation/NavigationController.ts | 92 ++++++++-- .../Controllers/NotesController.ts | 18 +- .../Controllers/SelectedItemsController.ts | 164 ++++++++++++------ .../Controllers/ViewControllerManager.ts | 50 +++++- 20 files changed, 421 insertions(+), 116 deletions(-) create mode 100644 packages/web/src/javascripts/Components/Preferences/Panes/General/Persistence.tsx rename packages/web/src/javascripts/Components/{RadioIndicator => Radio}/RadioIndicator.tsx (100%) create mode 100644 packages/web/src/javascripts/Components/Radio/StyledRadioInput.tsx create mode 100644 packages/web/src/javascripts/Controllers/Abstract/Persistable.ts create mode 100644 packages/web/src/javascripts/Controllers/Abstract/PersistenceService.ts diff --git a/packages/web/src/javascripts/Components/ContentListView/ContentList.tsx b/packages/web/src/javascripts/Components/ContentListView/ContentList.tsx index d5cf2882e..dcadcd0f6 100644 --- a/packages/web/src/javascripts/Components/ContentListView/ContentList.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/ContentList.tsx @@ -1,6 +1,5 @@ import { WebApplication } from '@/Application/Application' import { KeyboardKey } from '@standardnotes/ui-services' -import { UuidString } from '@standardnotes/snjs' import { observer } from 'mobx-react-lite' import { FunctionComponent, KeyboardEventHandler, UIEventHandler, useCallback } from 'react' import { FOCUSABLE_BUT_NOT_TABBABLE, NOTES_LIST_SCROLL_THRESHOLD } from '@/Constants/Constants' @@ -22,7 +21,7 @@ type Props = { navigationController: NavigationController notesController: NotesController selectionController: SelectedItemsController - selectedItems: Record + selectedUuids: SelectedItemsController['selectedUuids'] paginate: () => void } @@ -34,7 +33,7 @@ const ContentList: FunctionComponent = ({ navigationController, notesController, selectionController, - selectedItems, + selectedUuids, paginate, }) => { const { selectPreviousItem, selectNextItem } = selectionController @@ -82,7 +81,7 @@ const ContentList: FunctionComponent = ({ key={item.uuid} application={application} item={item} - selected={!!selectedItems[item.uuid]} + selected={selectedUuids.has(item.uuid)} hideDate={hideDate} hidePreview={hideNotePreview} hideTags={hideTags} diff --git a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx index 21b8bfab1..f5cca3dd2 100644 --- a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx @@ -109,7 +109,7 @@ const ContentListView: FunctionComponent = ({ searchBarElement, } = itemListController - const { selectedItems, selectNextItem, selectPreviousItem } = selectionController + const { selectedUuids, selectNextItem, selectPreviousItem } = selectionController const isFilesSmartView = useMemo( () => navigationController.selected?.uuid === SystemViewId.Files, @@ -276,7 +276,7 @@ const ContentListView: FunctionComponent = ({ {renderedItems.length ? ( = ({ viewControllerManager, application, extensionsLatestVersions }) => ( + diff --git a/packages/web/src/javascripts/Components/Preferences/Panes/General/Persistence.tsx b/packages/web/src/javascripts/Components/Preferences/Panes/General/Persistence.tsx new file mode 100644 index 000000000..71856a120 --- /dev/null +++ b/packages/web/src/javascripts/Components/Preferences/Panes/General/Persistence.tsx @@ -0,0 +1,51 @@ +import { WebApplication } from '@/Application/Application' +import StyledRadioInput from '@/Components/Radio/StyledRadioInput' +import { useState } from 'react' +import { Title } from '../../PreferencesComponents/Content' +import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup' +import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment' + +type Props = { + application: WebApplication +} + +export const ShouldPersistNoteStateKey = 'ShouldPersistNoteState' + +const Persistence = ({ application }: Props) => { + const [shouldPersistNoteState, setShouldPersistNoteState] = useState(application.getValue(ShouldPersistNoteStateKey)) + + const toggleStatePersistence = (shouldPersist: boolean) => { + application.setValue(ShouldPersistNoteStateKey, shouldPersist) + setShouldPersistNoteState(shouldPersist) + } + + return ( + + + When opening the app, show... + + + + + ) +} + +export default Persistence diff --git a/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx b/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx index d4212cc5b..ae71d64a0 100644 --- a/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx +++ b/packages/web/src/javascripts/Components/QuickSettingsMenu/QuickSettingsMenu.tsx @@ -16,7 +16,7 @@ import FocusModeSwitch from './FocusModeSwitch' import ThemesMenuButton from './ThemesMenuButton' import { ThemeItem } from './ThemeItem' import { sortThemes } from '@/Utils/SortThemes' -import RadioIndicator from '../RadioIndicator/RadioIndicator' +import RadioIndicator from '../Radio/RadioIndicator' import HorizontalSeparator from '../Shared/HorizontalSeparator' import { QuickSettingsController } from '@/Controllers/QuickSettingsController' import PanelSettingsSection from './PanelSettingsSection' diff --git a/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx b/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx index efe16a736..5258fcc74 100644 --- a/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx +++ b/packages/web/src/javascripts/Components/QuickSettingsMenu/ThemesMenuButton.tsx @@ -5,7 +5,7 @@ import Icon from '@/Components/Icon/Icon' import { usePremiumModal } from '@/Hooks/usePremiumModal' import Switch from '@/Components/Switch/Switch' import { ThemeItem } from './ThemeItem' -import RadioIndicator from '../RadioIndicator/RadioIndicator' +import RadioIndicator from '../Radio/RadioIndicator' import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon' import { isMobileScreen } from '@/Utils' diff --git a/packages/web/src/javascripts/Components/RadioIndicator/RadioIndicator.tsx b/packages/web/src/javascripts/Components/Radio/RadioIndicator.tsx similarity index 100% rename from packages/web/src/javascripts/Components/RadioIndicator/RadioIndicator.tsx rename to packages/web/src/javascripts/Components/Radio/RadioIndicator.tsx diff --git a/packages/web/src/javascripts/Components/Radio/StyledRadioInput.tsx b/packages/web/src/javascripts/Components/Radio/StyledRadioInput.tsx new file mode 100644 index 000000000..0f6e5a2b5 --- /dev/null +++ b/packages/web/src/javascripts/Components/Radio/StyledRadioInput.tsx @@ -0,0 +1,16 @@ +import { classNames } from '@/Utils/ConcatenateClassNames' +import { ComponentPropsWithoutRef } from 'react' +import RadioIndicator from './RadioIndicator' + +type Props = ComponentPropsWithoutRef<'input'> + +const StyledRadioInput = (props: Props) => { + return ( +
+ + +
+ ) +} + +export default StyledRadioInput diff --git a/packages/web/src/javascripts/Components/RevisionHistoryModal/HistoryListItem.tsx b/packages/web/src/javascripts/Components/RevisionHistoryModal/HistoryListItem.tsx index 21af3cef9..e5773c823 100644 --- a/packages/web/src/javascripts/Components/RevisionHistoryModal/HistoryListItem.tsx +++ b/packages/web/src/javascripts/Components/RevisionHistoryModal/HistoryListItem.tsx @@ -1,6 +1,6 @@ import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' import { FunctionComponent, ReactNode } from 'react' -import RadioIndicator from '../RadioIndicator/RadioIndicator' +import RadioIndicator from '../Radio/RadioIndicator' type HistoryListItemProps = { isSelected: boolean diff --git a/packages/web/src/javascripts/Components/Tags/SmartViewsListItem.tsx b/packages/web/src/javascripts/Components/Tags/SmartViewsListItem.tsx index 91a47d7d8..cc5005e1a 100644 --- a/packages/web/src/javascripts/Components/Tags/SmartViewsListItem.tsx +++ b/packages/web/src/javascripts/Components/Tags/SmartViewsListItem.tsx @@ -63,7 +63,9 @@ const SmartViewsListItem: FunctionComponent = ({ view, tagsState }) => { }, [setTitle, view]) const selectCurrentTag = useCallback(async () => { - await tagsState.setSelectedTag(view) + await tagsState.setSelectedTag(view, { + userTriggered: true, + }) toggleAppPane(AppPaneId.Items) }, [tagsState, toggleAppPane, view]) diff --git a/packages/web/src/javascripts/Components/Tags/TagsListItem.tsx b/packages/web/src/javascripts/Components/Tags/TagsListItem.tsx index b3b6bd50f..249e5df0d 100644 --- a/packages/web/src/javascripts/Components/Tags/TagsListItem.tsx +++ b/packages/web/src/javascripts/Components/Tags/TagsListItem.tsx @@ -88,7 +88,9 @@ export const TagsListItem: FunctionComponent = observer( ) const selectCurrentTag = useCallback(async () => { - await tagsState.setSelectedTag(tag) + await tagsState.setSelectedTag(tag, { + userTriggered: true, + }) toggleAppPane(AppPaneId.Items) }, [tagsState, tag, toggleAppPane]) diff --git a/packages/web/src/javascripts/Controllers/Abstract/Persistable.ts b/packages/web/src/javascripts/Controllers/Abstract/Persistable.ts new file mode 100644 index 000000000..981023827 --- /dev/null +++ b/packages/web/src/javascripts/Controllers/Abstract/Persistable.ts @@ -0,0 +1,4 @@ +export interface Persistable { + getPersistableValue(): T + hydrateFromPersistedValue(value: T | undefined): void +} diff --git a/packages/web/src/javascripts/Controllers/Abstract/PersistenceService.ts b/packages/web/src/javascripts/Controllers/Abstract/PersistenceService.ts new file mode 100644 index 000000000..ac00c12e4 --- /dev/null +++ b/packages/web/src/javascripts/Controllers/Abstract/PersistenceService.ts @@ -0,0 +1,60 @@ +import { WebApplication } from '@/Application/Application' +import { ShouldPersistNoteStateKey } from '@/Components/Preferences/Panes/General/Persistence' +import { ApplicationEvent, InternalEventBus } from '@standardnotes/snjs' +import { CrossControllerEvent } from '../CrossControllerEvent' + +const MasterPersistenceKey = 'master-persistence-key' + +export enum PersistenceKey { + SelectedItemsController = 'selected-items-controller', + NavigationController = 'navigation-controller', + ItemListController = 'item-list-controller', +} + +export type MasterPersistedValue = Record + +export class PersistenceService { + private unsubAppEventObserver: () => void + + constructor(private application: WebApplication, private eventBus: InternalEventBus) { + this.unsubAppEventObserver = this.application.addEventObserver(async (eventName) => { + if (!this.application) { + return + } + + void this.onAppEvent(eventName) + }) + } + + async onAppEvent(eventName: ApplicationEvent) { + if (eventName === ApplicationEvent.LocalDataLoaded) { + let shouldHydrateState = this.application.getValue(ShouldPersistNoteStateKey) + + if (typeof shouldHydrateState === 'undefined') { + this.application.setValue(ShouldPersistNoteStateKey, true) + shouldHydrateState = true + } + + this.eventBus.publish({ + type: CrossControllerEvent.HydrateFromPersistedValues, + payload: shouldHydrateState ? this.getPersistedValues() : undefined, + }) + } + } + + persistValues(values: MasterPersistedValue): void { + if (!this.application.isDatabaseLoaded()) { + return + } + + this.application.setValue(MasterPersistenceKey, values) + } + + getPersistedValues(): MasterPersistedValue { + return this.application.getValue(MasterPersistenceKey) as MasterPersistedValue + } + + deinit() { + this.unsubAppEventObserver() + } +} diff --git a/packages/web/src/javascripts/Controllers/CrossControllerEvent.ts b/packages/web/src/javascripts/Controllers/CrossControllerEvent.ts index 1c89dd2f9..064be502c 100644 --- a/packages/web/src/javascripts/Controllers/CrossControllerEvent.ts +++ b/packages/web/src/javascripts/Controllers/CrossControllerEvent.ts @@ -1,4 +1,6 @@ export enum CrossControllerEvent { TagChanged = 'TagChanged', ActiveEditorChanged = 'ActiveEditorChanged', + HydrateFromPersistedValues = 'HydrateFromPersistedValues', + RequestValuePersistence = 'RequestValuePersistence', } diff --git a/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts b/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts index 9c73fd671..098cf52c3 100644 --- a/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts +++ b/packages/web/src/javascripts/Controllers/ItemList/ItemListController.ts @@ -22,7 +22,6 @@ import { } from '@standardnotes/snjs' import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx' import { WebApplication } from '../../Application/Application' -import { AbstractViewController } from '../Abstract/AbstractViewController' import { WebDisplayOptions } from './WebDisplayOptions' import { NavigationController } from '../Navigation/NavigationController' import { CrossControllerEvent } from '../CrossControllerEvent' @@ -33,6 +32,8 @@ import { formatDateAndTimeForNote } from '@/Utils/DateUtils' import { PrefDefaults } from '@/Constants/PrefDefaults' import dayjs from 'dayjs' import { LinkingController } from '../LinkingController' +import { AbstractViewController } from '../Abstract/AbstractViewController' +import { Persistable } from '../Abstract/Persistable' const MinNoteCellHeight = 51.0 const DefaultListNumNotes = 20 @@ -45,10 +46,18 @@ enum ItemsReloadSource { DisplayOptionsChange, Pagination, TagChange, + UserTriggeredTagChange, FilterTextChange, } -export class ItemListController extends AbstractViewController implements InternalEventHandlerInterface { +export type ItemListControllerPersistableValue = { + displayOptions: DisplayOptions +} + +export class ItemListController + extends AbstractViewController + implements Persistable, InternalEventHandlerInterface +{ completedFullSync = false noteFilterText = '' notes: SNNote[] = [] @@ -204,6 +213,7 @@ export class ItemListController extends AbstractViewController implements Intern handleFilterTextChanged: action, optionsSubtitle: computed, + activeControllerItem: computed, }) window.onresize = () => { @@ -211,9 +221,25 @@ export class ItemListController extends AbstractViewController implements Intern } } + getPersistableValue = (): ItemListControllerPersistableValue => { + return { + displayOptions: this.displayOptions, + } + } + + hydrateFromPersistedValue = (state: ItemListControllerPersistableValue | undefined) => { + if (!state) { + return + } + if (state.displayOptions) { + this.displayOptions = state.displayOptions + } + } + async handleEvent(event: InternalEventInterface): Promise { if (event.type === CrossControllerEvent.TagChanged) { - this.handleTagChange() + const payload = event.payload as { userTriggered: boolean } + this.handleTagChange(payload.userTriggered) } else if (event.type === CrossControllerEvent.ActiveEditorChanged) { this.handleEditorChange().catch(console.error) } @@ -335,8 +361,10 @@ export class ItemListController extends AbstractViewController implements Intern ) } - private shouldSelectFirstItem = (itemsReloadSource: ItemsReloadSource, activeItem: SNNote | FileItem | undefined) => { - return itemsReloadSource === ItemsReloadSource.TagChange || !activeItem + private shouldSelectFirstItem = (itemsReloadSource: ItemsReloadSource) => { + return ( + itemsReloadSource === ItemsReloadSource.UserTriggeredTagChange || !this.selectionController.selectedUuids.size + ) } private shouldCloseActiveItem = (activeItem: SNNote | FileItem | undefined) => { @@ -359,7 +387,7 @@ export class ItemListController extends AbstractViewController implements Intern } private shouldSelectActiveItem = (activeItem: SNNote | FileItem | undefined) => { - return activeItem && !this.selectionController.selectedItems[activeItem.uuid] + return activeItem && !this.selectionController.isItemSelected(activeItem) } private async recomputeSelectionAfterItemsReload(itemsReloadSource: ItemsReloadSource) { @@ -371,7 +399,7 @@ export class ItemListController extends AbstractViewController implements Intern const activeItem = activeController?.item - if (this.shouldSelectFirstItem(itemsReloadSource, activeItem)) { + if (this.shouldSelectFirstItem(itemsReloadSource)) { await this.selectFirstItem() } else if (this.shouldCloseActiveItem(activeItem) && activeController) { this.closeItemController(activeController) @@ -500,6 +528,11 @@ export class ItemListController extends AbstractViewController implements Intern if (newDisplayOptions.sortBy !== currentSortBy) { await this.selectFirstItem() } + + this.eventBus.publish({ + type: CrossControllerEvent.RequestValuePersistence, + payload: undefined, + }) } async createNewNoteController(title?: string) { @@ -664,7 +697,7 @@ export class ItemListController extends AbstractViewController implements Intern this.application.itemControllerGroup.closeItemController(controller) } - handleTagChange = () => { + handleTagChange = (userTriggered: boolean) => { const activeNoteController = this.getActiveItemController() if (activeNoteController instanceof NoteViewController && activeNoteController.isTemplateNote) { this.closeItemController(activeNoteController) @@ -682,7 +715,7 @@ export class ItemListController extends AbstractViewController implements Intern this.reloadNotesDisplayOptions() - void this.reloadItems(ItemsReloadSource.TagChange) + void this.reloadItems(userTriggered ? ItemsReloadSource.UserTriggeredTagChange : ItemsReloadSource.TagChange) } onFilterEnter = () => { diff --git a/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts b/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts index 13583889b..18fe088e9 100644 --- a/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts +++ b/packages/web/src/javascripts/Controllers/Navigation/NavigationController.ts @@ -15,19 +15,28 @@ import { InternalEventBus, InternalEventPublishStrategy, } from '@standardnotes/snjs' -import { action, computed, makeAutoObservable, makeObservable, observable, runInAction } from 'mobx' +import { action, computed, makeAutoObservable, makeObservable, observable, reaction, runInAction } from 'mobx' import { WebApplication } from '../../Application/Application' import { FeaturesController } from '../FeaturesController' -import { AbstractViewController } from '../Abstract/AbstractViewController' import { destroyAllObjectProperties } from '@/Utils' import { isValidFutureSiblings, rootTags, tagSiblings } from './Utils' import { AnyTag } from './AnyTagType' import { CrossControllerEvent } from '../CrossControllerEvent' +import { AbstractViewController } from '../Abstract/AbstractViewController' +import { Persistable } from '../Abstract/Persistable' -export class NavigationController extends AbstractViewController { +export type NavigationControllerPersistableValue = { + selectedTagUuid: AnyTag['uuid'] +} + +export class NavigationController + extends AbstractViewController + implements Persistable +{ tags: SNTag[] = [] smartViews: SmartView[] = [] allNotesCount_ = 0 + selectedUuid: AnyTag['uuid'] | undefined = undefined selected_: AnyTag | undefined previouslySelected_: AnyTag | undefined editing_: SNTag | SmartView | undefined @@ -63,12 +72,12 @@ export class NavigationController extends AbstractViewController { allNotesCount: computed, setAllNotesCount: action, - selected_: observable.ref, + selected_: observable, previouslySelected_: observable.ref, previouslySelected: computed, editing_: observable.ref, selected: computed, - selectedUuid: computed, + selectedUuid: observable, editingTag: computed, addingSubtagTo: observable, @@ -94,6 +103,8 @@ export class NavigationController extends AbstractViewController { setContextMenuMaxHeight: action, isInFilesView: computed, + + hydrateFromPersistedValue: action, }) this.disposers.push( @@ -103,25 +114,23 @@ export class NavigationController extends AbstractViewController { this.smartViews = this.application.items.getSmartViews() - const currrentSelectedTag = this.selected_ - - if (!currrentSelectedTag) { - this.setSelectedTagInstance(this.smartViews[0]) + const currentSelectedTag = this.selected_ + if (!currentSelectedTag) { return } const updatedReference = - FindItem(changed, currrentSelectedTag.uuid) || FindItem(this.smartViews, currrentSelectedTag.uuid) + FindItem(changed, currentSelectedTag.uuid) || FindItem(this.smartViews, currentSelectedTag.uuid) if (updatedReference) { this.setSelectedTagInstance(updatedReference as AnyTag) } - if (isSystemView(currrentSelectedTag as SmartView)) { + if (isSystemView(currentSelectedTag as SmartView)) { return } - if (FindItem(removed, currrentSelectedTag.uuid)) { + if (FindItem(removed, currentSelectedTag.uuid)) { this.setSelectedTagInstance(this.smartViews[0]) } }) @@ -140,6 +149,52 @@ export class NavigationController extends AbstractViewController { } }), ) + + this.disposers.push( + reaction( + () => this.selectedUuid, + () => { + eventBus.publish({ + type: CrossControllerEvent.RequestValuePersistence, + payload: undefined, + }) + }, + ), + ) + } + + findAndSetTag = (uuid: UuidString) => { + const tagToSelect = [...this.tags, ...this.smartViews].find((tag) => tag.uuid === uuid) + if (tagToSelect) { + void this.setSelectedTag(tagToSelect) + } + } + + selectHydratedTagOrDefault = () => { + if (this.selectedUuid && !this.selected_) { + this.findAndSetTag(this.selectedUuid) + } + + if (!this.selectedUuid) { + void this.selectHomeNavigationView() + } + } + + getPersistableValue = (): NavigationControllerPersistableValue => { + return { + selectedTagUuid: this.selectedUuid ? this.selectedUuid : SystemViewId.AllNotes, + } + } + + hydrateFromPersistedValue = (state: NavigationControllerPersistableValue | undefined) => { + if (!state) { + void this.selectHomeNavigationView() + return + } + if (state.selectedTagUuid) { + this.selectedUuid = state.selectedTagUuid + this.selectHydratedTagOrDefault() + } } override deinit() { @@ -358,7 +413,7 @@ export class NavigationController extends AbstractViewController { return this.selected_ } - public async setSelectedTag(tag: AnyTag | undefined) { + public async setSelectedTag(tag: AnyTag | undefined, { userTriggered } = { userTriggered: false }) { if (tag && tag.conflictOf) { this.application.mutator .changeAndSaveItem(tag, (mutator) => { @@ -384,7 +439,7 @@ export class NavigationController extends AbstractViewController { await this.eventBus.publishSync( { type: CrossControllerEvent.TagChanged, - payload: { tag, previousTag: this.previouslySelected_ }, + payload: { tag, previousTag: this.previouslySelected_, userTriggered: userTriggered }, }, InternalEventPublishStrategy.SEQUENCE, ) @@ -407,7 +462,10 @@ export class NavigationController extends AbstractViewController { } private setSelectedTagInstance(tag: AnyTag | undefined): void { - runInAction(() => (this.selected_ = tag)) + runInAction(() => { + this.selected_ = tag + this.selectedUuid = tag ? tag.uuid : undefined + }) } public setExpanded(tag: SNTag, expanded: boolean) { @@ -418,10 +476,6 @@ export class NavigationController extends AbstractViewController { .catch(console.error) } - public get selectedUuid(): UuidString | undefined { - return this.selected_?.uuid - } - public get editingTag(): SNTag | SmartView | undefined { return this.editing_ } diff --git a/packages/web/src/javascripts/Controllers/NotesController.ts b/packages/web/src/javascripts/Controllers/NotesController.ts index 310b80d45..568a878d6 100644 --- a/packages/web/src/javascripts/Controllers/NotesController.ts +++ b/packages/web/src/javascripts/Controllers/NotesController.ts @@ -63,20 +63,6 @@ export class NotesController extends AbstractViewController { this.itemListController = itemListController this.disposers.push( - this.application.streamItems(ContentType.Note, ({ changed, inserted, removed }) => { - runInAction(() => { - for (const removedNote of removed) { - this.selectionController.deselectItem(removedNote) - } - - for (const note of [...changed, ...inserted]) { - if (this.selectionController.isItemSelected(note)) { - this.selectionController.updateReferenceOfSelectedItem(note) - } - } - }) - }), - this.application.itemControllerGroup.addActiveControllerChangeObserver(() => { const controllers = this.application.itemControllerGroup.itemControllers @@ -278,7 +264,7 @@ export class NotesController extends AbstractViewController { }) runInAction(() => { - this.selectionController.setSelectedItems({}) + this.selectionController.deselectAll() this.contextMenuOpen = false }) } @@ -295,7 +281,7 @@ export class NotesController extends AbstractViewController { } unselectNotes(): void { - this.selectionController.setSelectedItems({}) + this.selectionController.deselectAll() } getSpellcheckStateForNote(note: SNNote) { diff --git a/packages/web/src/javascripts/Controllers/SelectedItemsController.ts b/packages/web/src/javascripts/Controllers/SelectedItemsController.ts index 257ff08e1..9cef4f4b5 100644 --- a/packages/web/src/javascripts/Controllers/SelectedItemsController.ts +++ b/packages/web/src/javascripts/Controllers/SelectedItemsController.ts @@ -7,17 +7,25 @@ import { SNNote, UuidString, InternalEventBus, + isFile, } from '@standardnotes/snjs' -import { action, computed, makeObservable, observable, runInAction } from 'mobx' +import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx' import { WebApplication } from '../Application/Application' import { AbstractViewController } from './Abstract/AbstractViewController' +import { Persistable } from './Abstract/Persistable' +import { CrossControllerEvent } from './CrossControllerEvent' import { ItemListController } from './ItemList/ItemListController' -type SelectedItems = Record +export type SelectionControllerPersistableValue = { + selectedUuids: UuidString[] +} -export class SelectedItemsController extends AbstractViewController { +export class SelectedItemsController + extends AbstractViewController + implements Persistable +{ lastSelectedItem: ListableContentItem | undefined - selectedItems: SelectedItems = {} + selectedUuids: Set = observable(new Set()) private itemListController!: ItemListController override deinit(): void { @@ -29,7 +37,7 @@ export class SelectedItemsController extends AbstractViewController { super(application, eventBus) makeObservable(this, { - selectedItems: observable, + selectedUuids: observable, selectedItemsCount: computed, selectedFiles: computed, @@ -37,30 +45,50 @@ export class SelectedItemsController extends AbstractViewController { firstSelectedItem: computed, selectItem: action, - setSelectedItems: action, + setSelectedUuids: action, + + hydrateFromPersistedValue: action, }) + + this.disposers.push( + reaction( + () => this.selectedUuids, + () => { + eventBus.publish({ + type: CrossControllerEvent.RequestValuePersistence, + payload: undefined, + }) + }, + ), + ) + } + + getPersistableValue = (): SelectionControllerPersistableValue => { + return { + selectedUuids: Array.from(this.selectedUuids), + } + } + + hydrateFromPersistedValue = (state: SelectionControllerPersistableValue | undefined): void => { + if (!state) { + return + } + if (!this.selectedUuids.size && state.selectedUuids.length > 0) { + void this.selectUuids(state.selectedUuids) + } } public setServicesPostConstruction(itemListController: ItemListController) { this.itemListController = itemListController this.disposers.push( - this.application.streamItems( - [ContentType.Note, ContentType.File], - ({ changed, inserted, removed }) => { - runInAction(() => { - for (const removedNote of removed) { - delete this.selectedItems[removedNote.uuid] - } - - for (const item of [...changed, ...inserted]) { - if (this.selectedItems[item.uuid]) { - this.selectedItems[item.uuid] = item - } - } - }) - }, - ), + this.application.streamItems([ContentType.Note, ContentType.File], ({ removed }) => { + runInAction(() => { + for (const removedNote of removed) { + this.removeFromSelectedUuids(removedNote.uuid) + } + }) + }), ) } @@ -69,7 +97,7 @@ export class SelectedItemsController extends AbstractViewController { } get selectedItemsCount(): number { - return Object.keys(this.selectedItems).length + return this.selectedUuids.size } get selectedFiles(): FileItem[] { @@ -81,21 +109,31 @@ export class SelectedItemsController extends AbstractViewController { } get firstSelectedItem() { - return this.getSelectedItems()[0] + return this.application.items.findSureItem(Array.from(this.selectedUuids)[0]) as ListableContentItem } getSelectedItems = (contentType?: ContentType): T[] => { - return Object.values(this.selectedItems).filter((item) => { - return !contentType ? true : item.content_type === contentType - }) as T[] + const uuids = Array.from(this.selectedUuids) + return uuids.length > 0 + ? (uuids + .map((uuid) => this.application.items.findSureItem(uuid)) + .filter((item) => { + return !contentType ? true : item.content_type === contentType + }) as T[]) + : [] } - setSelectedItems = (selectedItems: SelectedItems) => { - this.selectedItems = selectedItems + setSelectedUuids = (selectedUuids: Set) => { + this.selectedUuids = new Set(selectedUuids) + } + + private removeFromSelectedUuids = (uuid: UuidString) => { + this.selectedUuids.delete(uuid) + this.setSelectedUuids(this.selectedUuids) } public deselectItem = (item: { uuid: ListableContentItem['uuid'] }): void => { - delete this.selectedItems[item.uuid] + this.removeFromSelectedUuids(item.uuid) if (item.uuid === this.lastSelectedItem?.uuid) { this.lastSelectedItem = undefined @@ -103,11 +141,7 @@ export class SelectedItemsController extends AbstractViewController { } public isItemSelected = (item: ListableContentItem): boolean => { - return this.selectedItems[item.uuid] != undefined - } - - public updateReferenceOfSelectedItem = (item: ListableContentItem): void => { - this.selectedItems[item.uuid] = item + return this.selectedUuids.has(item.uuid) } private selectItemsRange = async ({ @@ -138,7 +172,7 @@ export class SelectedItemsController extends AbstractViewController { for (const item of authorizedItems) { runInAction(() => { - this.selectedItems[item.uuid] = item + this.setSelectedUuids(this.selectedUuids.add(item.uuid)) this.lastSelectedItem = item }) } @@ -147,7 +181,7 @@ export class SelectedItemsController extends AbstractViewController { cancelMultipleSelection = () => { this.io.cancelAllKeyboardModifiers() - const firstSelectedItem = this.getSelectedItems()[0] + const firstSelectedItem = this.firstSelectedItem if (firstSelectedItem) { this.replaceSelection(firstSelectedItem) @@ -157,9 +191,8 @@ export class SelectedItemsController extends AbstractViewController { } private replaceSelection = (item: ListableContentItem): void => { - this.setSelectedItems({ - [item.uuid]: item, - }) + this.deselectAll() + this.setSelectedUuids(this.selectedUuids.add(item.uuid)) this.lastSelectedItem = item } @@ -171,12 +204,25 @@ export class SelectedItemsController extends AbstractViewController { }) } - private deselectAll = (): void => { - this.setSelectedItems({}) + deselectAll = (): void => { + this.selectedUuids.clear() + this.setSelectedUuids(this.selectedUuids) this.lastSelectedItem = undefined } + openSingleSelectedItem = async () => { + if (this.selectedItemsCount === 1) { + const item = this.firstSelectedItem + + if (item.content_type === ContentType.Note) { + await this.itemListController.openNote(item.uuid) + } else if (item.content_type === ContentType.File) { + await this.itemListController.openFile(item.uuid) + } + } + } + selectItem = async ( uuid: UuidString, userTriggered?: boolean, @@ -197,33 +243,25 @@ export class SelectedItemsController extends AbstractViewController { const isAuthorizedForAccess = await this.application.protections.authorizeItemAccess(item) if (userTriggered && (hasMeta || hasCtrl)) { - if (this.selectedItems[uuid] && hasMoreThanOneSelected) { - delete this.selectedItems[uuid] + if (this.selectedUuids.has(uuid) && hasMoreThanOneSelected) { + this.removeFromSelectedUuids(uuid) } else if (isAuthorizedForAccess) { - this.selectedItems[uuid] = item + this.setSelectedUuids(this.selectedUuids.add(uuid)) this.lastSelectedItem = item } } else if (userTriggered && hasShift) { await this.selectItemsRange({ selectedItem: item }) } else { - const shouldSelectNote = hasMoreThanOneSelected || !this.selectedItems[uuid] + const shouldSelectNote = hasMoreThanOneSelected || !this.selectedUuids.has(uuid) if (shouldSelectNote && isAuthorizedForAccess) { this.replaceSelection(item) } } - if (this.selectedItemsCount === 1) { - const item = Object.values(this.selectedItems)[0] - - if (item.content_type === ContentType.Note) { - await this.itemListController.openNote(item.uuid) - } else if (item.content_type === ContentType.File) { - await this.itemListController.openFile(item.uuid) - } - } + await this.openSingleSelectedItem() return { - didSelect: this.selectedItems[uuid] != undefined, + didSelect: this.selectedUuids.has(uuid), } } @@ -243,6 +281,20 @@ export class SelectedItemsController extends AbstractViewController { } } + selectUuids = async (uuids: UuidString[], userTriggered = false) => { + const itemsForUuids = this.application.items.findItems(uuids) + if (itemsForUuids.length < 1) { + return + } + if (!userTriggered && itemsForUuids.some((item) => item.protected && isFile(item))) { + return + } + this.setSelectedUuids(new Set(uuids)) + if (itemsForUuids.length === 1) { + void this.openSingleSelectedItem() + } + } + selectNextItem = () => { const displayableItems = this.itemListController.items diff --git a/packages/web/src/javascripts/Controllers/ViewControllerManager.ts b/packages/web/src/javascripts/Controllers/ViewControllerManager.ts index 0da6bdb9f..d82433cfa 100644 --- a/packages/web/src/javascripts/Controllers/ViewControllerManager.ts +++ b/packages/web/src/javascripts/Controllers/ViewControllerManager.ts @@ -11,13 +11,15 @@ import { ItemCounterInterface, ItemCounter, SubscriptionClientInterface, + InternalEventHandlerInterface, + InternalEventInterface, } from '@standardnotes/snjs' import { action, makeObservable, observable } from 'mobx' import { ActionsMenuController } from './ActionsMenuController' import { FeaturesController } from './FeaturesController' import { FilesController } from './FilesController' import { NotesController } from './NotesController' -import { ItemListController } from './ItemList/ItemListController' +import { ItemListController, ItemListControllerPersistableValue } from './ItemList/ItemListController' import { NoAccountWarningController } from './NoAccountWarningController' import { PreferencesController } from './PreferencesController' import { PurchaseFlowController } from './PurchaseFlow/PurchaseFlowController' @@ -25,14 +27,16 @@ import { QuickSettingsController } from './QuickSettingsController' import { SearchOptionsController } from './SearchOptionsController' import { SubscriptionController } from './Subscription/SubscriptionController' import { SyncStatusController } from './SyncStatusController' -import { NavigationController } from './Navigation/NavigationController' +import { NavigationController, NavigationControllerPersistableValue } from './Navigation/NavigationController' import { FilePreviewModalController } from './FilePreviewModalController' -import { SelectedItemsController } from './SelectedItemsController' +import { SelectedItemsController, SelectionControllerPersistableValue } from './SelectedItemsController' import { HistoryModalController } from './NoteHistory/HistoryModalController' import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane' import { LinkingController } from './LinkingController' +import { MasterPersistedValue, PersistenceKey, PersistenceService } from './Abstract/PersistenceService' +import { CrossControllerEvent } from './CrossControllerEvent' -export class ViewControllerManager { +export class ViewControllerManager implements InternalEventHandlerInterface { readonly enableUnfinishedFeatures: boolean = window?.enabledUnfinishedFeatures private unsubAppEventObserver!: () => void @@ -65,10 +69,16 @@ export class ViewControllerManager { private eventBus: InternalEventBus private itemCounter: ItemCounterInterface private subscriptionManager: SubscriptionClientInterface + private persistenceService: PersistenceService constructor(public application: WebApplication, private device: WebOrDesktopDeviceInterface) { this.eventBus = new InternalEventBus() + this.persistenceService = new PersistenceService(application, this.eventBus) + + this.eventBus.addEventHandler(this, CrossControllerEvent.HydrateFromPersistedValues) + this.eventBus.addEventHandler(this, CrossControllerEvent.RequestValuePersistence) + this.itemCounter = new ItemCounter() this.subscriptionManager = application.subscriptions @@ -173,6 +183,9 @@ export class ViewControllerManager { ;(this.quickSettingsMenuController as unknown) = undefined ;(this.syncStatusController as unknown) = undefined + this.persistenceService.deinit() + ;(this.persistenceService as unknown) = undefined + this.actionsMenuController.reset() ;(this.actionsMenuController as unknown) = undefined @@ -264,4 +277,33 @@ export class ViewControllerManager { } }) } + + persistValues = (): void => { + const values: MasterPersistedValue = { + [PersistenceKey.SelectedItemsController]: this.selectionController.getPersistableValue(), + [PersistenceKey.NavigationController]: this.navigationController.getPersistableValue(), + [PersistenceKey.ItemListController]: this.itemListController.getPersistableValue(), + } + + this.persistenceService.persistValues(values) + } + + hydrateFromPersistedValues = (values: MasterPersistedValue | undefined): void => { + const itemListState = values?.[PersistenceKey.ItemListController] as ItemListControllerPersistableValue + this.itemListController.hydrateFromPersistedValue(itemListState) + + const selectedItemsState = values?.[PersistenceKey.SelectedItemsController] as SelectionControllerPersistableValue + this.selectionController.hydrateFromPersistedValue(selectedItemsState) + + const navigationState = values?.[PersistenceKey.NavigationController] as NavigationControllerPersistableValue + this.navigationController.hydrateFromPersistedValue(navigationState) + } + + async handleEvent(event: InternalEventInterface): Promise { + if (event.type === CrossControllerEvent.HydrateFromPersistedValues) { + this.hydrateFromPersistedValues(event.payload as MasterPersistedValue | undefined) + } else if (event.type === CrossControllerEvent.RequestValuePersistence) { + this.persistValues() + } + } }