feat: persist selected tag & note locally (#1851)

This commit is contained in:
Aman Harwara
2022-10-20 18:56:59 +05:30
committed by GitHub
parent 6c47f95748
commit 4432f1cb4c
20 changed files with 421 additions and 116 deletions

View File

@@ -1,6 +1,5 @@
import { WebApplication } from '@/Application/Application' import { WebApplication } from '@/Application/Application'
import { KeyboardKey } from '@standardnotes/ui-services' import { KeyboardKey } from '@standardnotes/ui-services'
import { UuidString } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite' import { observer } from 'mobx-react-lite'
import { FunctionComponent, KeyboardEventHandler, UIEventHandler, useCallback } from 'react' import { FunctionComponent, KeyboardEventHandler, UIEventHandler, useCallback } from 'react'
import { FOCUSABLE_BUT_NOT_TABBABLE, NOTES_LIST_SCROLL_THRESHOLD } from '@/Constants/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE, NOTES_LIST_SCROLL_THRESHOLD } from '@/Constants/Constants'
@@ -22,7 +21,7 @@ type Props = {
navigationController: NavigationController navigationController: NavigationController
notesController: NotesController notesController: NotesController
selectionController: SelectedItemsController selectionController: SelectedItemsController
selectedItems: Record<UuidString, ListableContentItem> selectedUuids: SelectedItemsController['selectedUuids']
paginate: () => void paginate: () => void
} }
@@ -34,7 +33,7 @@ const ContentList: FunctionComponent<Props> = ({
navigationController, navigationController,
notesController, notesController,
selectionController, selectionController,
selectedItems, selectedUuids,
paginate, paginate,
}) => { }) => {
const { selectPreviousItem, selectNextItem } = selectionController const { selectPreviousItem, selectNextItem } = selectionController
@@ -82,7 +81,7 @@ const ContentList: FunctionComponent<Props> = ({
key={item.uuid} key={item.uuid}
application={application} application={application}
item={item} item={item}
selected={!!selectedItems[item.uuid]} selected={selectedUuids.has(item.uuid)}
hideDate={hideDate} hideDate={hideDate}
hidePreview={hideNotePreview} hidePreview={hideNotePreview}
hideTags={hideTags} hideTags={hideTags}

View File

@@ -109,7 +109,7 @@ const ContentListView: FunctionComponent<Props> = ({
searchBarElement, searchBarElement,
} = itemListController } = itemListController
const { selectedItems, selectNextItem, selectPreviousItem } = selectionController const { selectedUuids, selectNextItem, selectPreviousItem } = selectionController
const isFilesSmartView = useMemo( const isFilesSmartView = useMemo(
() => navigationController.selected?.uuid === SystemViewId.Files, () => navigationController.selected?.uuid === SystemViewId.Files,
@@ -276,7 +276,7 @@ const ContentListView: FunctionComponent<Props> = ({
{renderedItems.length ? ( {renderedItems.length ? (
<ContentList <ContentList
items={renderedItems} items={renderedItems}
selectedItems={selectedItems} selectedUuids={selectedUuids}
application={application} application={application}
paginate={paginate} paginate={paginate}
filesController={filesController} filesController={filesController}

View File

@@ -5,7 +5,7 @@ import { SwitchProps } from '@/Components/Switch/SwitchProps'
import { IconType } from '@standardnotes/snjs' import { IconType } from '@standardnotes/snjs'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { MenuItemType } from './MenuItemType' import { MenuItemType } from './MenuItemType'
import RadioIndicator from '../RadioIndicator/RadioIndicator' import RadioIndicator from '../Radio/RadioIndicator'
type MenuItemProps = { type MenuItemProps = {
children: ReactNode children: ReactNode

View File

@@ -9,6 +9,7 @@ import LabsPane from './Labs/Labs'
import Advanced from '@/Components/Preferences/Panes/General/Advanced/AdvancedSection' import Advanced from '@/Components/Preferences/Panes/General/Advanced/AdvancedSection'
import PreferencesPane from '../../PreferencesComponents/PreferencesPane' import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
import PlaintextDefaults from './PlaintextDefaults' import PlaintextDefaults from './PlaintextDefaults'
import Persistence from './Persistence'
type Props = { type Props = {
viewControllerManager: ViewControllerManager viewControllerManager: ViewControllerManager
@@ -18,6 +19,7 @@ type Props = {
const General: FunctionComponent<Props> = ({ viewControllerManager, application, extensionsLatestVersions }) => ( const General: FunctionComponent<Props> = ({ viewControllerManager, application, extensionsLatestVersions }) => (
<PreferencesPane> <PreferencesPane>
<Persistence application={application} />
<PlaintextDefaults application={application} /> <PlaintextDefaults application={application} />
<Defaults application={application} /> <Defaults application={application} />
<Tools application={application} /> <Tools application={application} />

View File

@@ -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 (
<PreferencesGroup>
<PreferencesSegment>
<Title className="mb-2">When opening the app, show...</Title>
<label className="mb-2 flex items-center gap-2 text-sm font-medium">
<StyledRadioInput
name="state-persistence"
checked={!shouldPersistNoteState}
onChange={(event) => {
toggleStatePersistence(!event.target.checked)
}}
/>
The first note in the list
</label>
<label className="flex items-center gap-2 text-sm font-medium">
<StyledRadioInput
name="state-persistence"
checked={!!shouldPersistNoteState}
onChange={(event) => {
toggleStatePersistence(event.target.checked)
}}
/>
The last viewed note
</label>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default Persistence

View File

@@ -16,7 +16,7 @@ import FocusModeSwitch from './FocusModeSwitch'
import ThemesMenuButton from './ThemesMenuButton' import ThemesMenuButton from './ThemesMenuButton'
import { ThemeItem } from './ThemeItem' import { ThemeItem } from './ThemeItem'
import { sortThemes } from '@/Utils/SortThemes' import { sortThemes } from '@/Utils/SortThemes'
import RadioIndicator from '../RadioIndicator/RadioIndicator' import RadioIndicator from '../Radio/RadioIndicator'
import HorizontalSeparator from '../Shared/HorizontalSeparator' import HorizontalSeparator from '../Shared/HorizontalSeparator'
import { QuickSettingsController } from '@/Controllers/QuickSettingsController' import { QuickSettingsController } from '@/Controllers/QuickSettingsController'
import PanelSettingsSection from './PanelSettingsSection' import PanelSettingsSection from './PanelSettingsSection'

View File

@@ -5,7 +5,7 @@ import Icon from '@/Components/Icon/Icon'
import { usePremiumModal } from '@/Hooks/usePremiumModal' import { usePremiumModal } from '@/Hooks/usePremiumModal'
import Switch from '@/Components/Switch/Switch' import Switch from '@/Components/Switch/Switch'
import { ThemeItem } from './ThemeItem' import { ThemeItem } from './ThemeItem'
import RadioIndicator from '../RadioIndicator/RadioIndicator' import RadioIndicator from '../Radio/RadioIndicator'
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon' import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
import { isMobileScreen } from '@/Utils' import { isMobileScreen } from '@/Utils'

View File

@@ -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 (
<div className="flex">
<input type="radio" className={classNames('h-0 w-0 opacity-0', props.className)} {...props} />
<RadioIndicator checked={!!props.checked} />
</div>
)
}
export default StyledRadioInput

View File

@@ -1,6 +1,6 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants' import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { FunctionComponent, ReactNode } from 'react' import { FunctionComponent, ReactNode } from 'react'
import RadioIndicator from '../RadioIndicator/RadioIndicator' import RadioIndicator from '../Radio/RadioIndicator'
type HistoryListItemProps = { type HistoryListItemProps = {
isSelected: boolean isSelected: boolean

View File

@@ -63,7 +63,9 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
}, [setTitle, view]) }, [setTitle, view])
const selectCurrentTag = useCallback(async () => { const selectCurrentTag = useCallback(async () => {
await tagsState.setSelectedTag(view) await tagsState.setSelectedTag(view, {
userTriggered: true,
})
toggleAppPane(AppPaneId.Items) toggleAppPane(AppPaneId.Items)
}, [tagsState, toggleAppPane, view]) }, [tagsState, toggleAppPane, view])

View File

@@ -88,7 +88,9 @@ export const TagsListItem: FunctionComponent<Props> = observer(
) )
const selectCurrentTag = useCallback(async () => { const selectCurrentTag = useCallback(async () => {
await tagsState.setSelectedTag(tag) await tagsState.setSelectedTag(tag, {
userTriggered: true,
})
toggleAppPane(AppPaneId.Items) toggleAppPane(AppPaneId.Items)
}, [tagsState, tag, toggleAppPane]) }, [tagsState, tag, toggleAppPane])

View File

@@ -0,0 +1,4 @@
export interface Persistable<T> {
getPersistableValue(): T
hydrateFromPersistedValue(value: T | undefined): void
}

View File

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

View File

@@ -1,4 +1,6 @@
export enum CrossControllerEvent { export enum CrossControllerEvent {
TagChanged = 'TagChanged', TagChanged = 'TagChanged',
ActiveEditorChanged = 'ActiveEditorChanged', ActiveEditorChanged = 'ActiveEditorChanged',
HydrateFromPersistedValues = 'HydrateFromPersistedValues',
RequestValuePersistence = 'RequestValuePersistence',
} }

View File

@@ -22,7 +22,6 @@ import {
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx' import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
import { WebApplication } from '../../Application/Application' import { WebApplication } from '../../Application/Application'
import { AbstractViewController } from '../Abstract/AbstractViewController'
import { WebDisplayOptions } from './WebDisplayOptions' import { WebDisplayOptions } from './WebDisplayOptions'
import { NavigationController } from '../Navigation/NavigationController' import { NavigationController } from '../Navigation/NavigationController'
import { CrossControllerEvent } from '../CrossControllerEvent' import { CrossControllerEvent } from '../CrossControllerEvent'
@@ -33,6 +32,8 @@ import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
import { PrefDefaults } from '@/Constants/PrefDefaults' import { PrefDefaults } from '@/Constants/PrefDefaults'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { LinkingController } from '../LinkingController' import { LinkingController } from '../LinkingController'
import { AbstractViewController } from '../Abstract/AbstractViewController'
import { Persistable } from '../Abstract/Persistable'
const MinNoteCellHeight = 51.0 const MinNoteCellHeight = 51.0
const DefaultListNumNotes = 20 const DefaultListNumNotes = 20
@@ -45,10 +46,18 @@ enum ItemsReloadSource {
DisplayOptionsChange, DisplayOptionsChange,
Pagination, Pagination,
TagChange, TagChange,
UserTriggeredTagChange,
FilterTextChange, FilterTextChange,
} }
export class ItemListController extends AbstractViewController implements InternalEventHandlerInterface { export type ItemListControllerPersistableValue = {
displayOptions: DisplayOptions
}
export class ItemListController
extends AbstractViewController
implements Persistable<ItemListControllerPersistableValue>, InternalEventHandlerInterface
{
completedFullSync = false completedFullSync = false
noteFilterText = '' noteFilterText = ''
notes: SNNote[] = [] notes: SNNote[] = []
@@ -204,6 +213,7 @@ export class ItemListController extends AbstractViewController implements Intern
handleFilterTextChanged: action, handleFilterTextChanged: action,
optionsSubtitle: computed, optionsSubtitle: computed,
activeControllerItem: computed,
}) })
window.onresize = () => { 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<void> { async handleEvent(event: InternalEventInterface): Promise<void> {
if (event.type === CrossControllerEvent.TagChanged) { if (event.type === CrossControllerEvent.TagChanged) {
this.handleTagChange() const payload = event.payload as { userTriggered: boolean }
this.handleTagChange(payload.userTriggered)
} else if (event.type === CrossControllerEvent.ActiveEditorChanged) { } else if (event.type === CrossControllerEvent.ActiveEditorChanged) {
this.handleEditorChange().catch(console.error) this.handleEditorChange().catch(console.error)
} }
@@ -335,8 +361,10 @@ export class ItemListController extends AbstractViewController implements Intern
) )
} }
private shouldSelectFirstItem = (itemsReloadSource: ItemsReloadSource, activeItem: SNNote | FileItem | undefined) => { private shouldSelectFirstItem = (itemsReloadSource: ItemsReloadSource) => {
return itemsReloadSource === ItemsReloadSource.TagChange || !activeItem return (
itemsReloadSource === ItemsReloadSource.UserTriggeredTagChange || !this.selectionController.selectedUuids.size
)
} }
private shouldCloseActiveItem = (activeItem: SNNote | FileItem | undefined) => { private shouldCloseActiveItem = (activeItem: SNNote | FileItem | undefined) => {
@@ -359,7 +387,7 @@ export class ItemListController extends AbstractViewController implements Intern
} }
private shouldSelectActiveItem = (activeItem: SNNote | FileItem | undefined) => { private shouldSelectActiveItem = (activeItem: SNNote | FileItem | undefined) => {
return activeItem && !this.selectionController.selectedItems[activeItem.uuid] return activeItem && !this.selectionController.isItemSelected(activeItem)
} }
private async recomputeSelectionAfterItemsReload(itemsReloadSource: ItemsReloadSource) { private async recomputeSelectionAfterItemsReload(itemsReloadSource: ItemsReloadSource) {
@@ -371,7 +399,7 @@ export class ItemListController extends AbstractViewController implements Intern
const activeItem = activeController?.item const activeItem = activeController?.item
if (this.shouldSelectFirstItem(itemsReloadSource, activeItem)) { if (this.shouldSelectFirstItem(itemsReloadSource)) {
await this.selectFirstItem() await this.selectFirstItem()
} else if (this.shouldCloseActiveItem(activeItem) && activeController) { } else if (this.shouldCloseActiveItem(activeItem) && activeController) {
this.closeItemController(activeController) this.closeItemController(activeController)
@@ -500,6 +528,11 @@ export class ItemListController extends AbstractViewController implements Intern
if (newDisplayOptions.sortBy !== currentSortBy) { if (newDisplayOptions.sortBy !== currentSortBy) {
await this.selectFirstItem() await this.selectFirstItem()
} }
this.eventBus.publish({
type: CrossControllerEvent.RequestValuePersistence,
payload: undefined,
})
} }
async createNewNoteController(title?: string) { async createNewNoteController(title?: string) {
@@ -664,7 +697,7 @@ export class ItemListController extends AbstractViewController implements Intern
this.application.itemControllerGroup.closeItemController(controller) this.application.itemControllerGroup.closeItemController(controller)
} }
handleTagChange = () => { handleTagChange = (userTriggered: boolean) => {
const activeNoteController = this.getActiveItemController() const activeNoteController = this.getActiveItemController()
if (activeNoteController instanceof NoteViewController && activeNoteController.isTemplateNote) { if (activeNoteController instanceof NoteViewController && activeNoteController.isTemplateNote) {
this.closeItemController(activeNoteController) this.closeItemController(activeNoteController)
@@ -682,7 +715,7 @@ export class ItemListController extends AbstractViewController implements Intern
this.reloadNotesDisplayOptions() this.reloadNotesDisplayOptions()
void this.reloadItems(ItemsReloadSource.TagChange) void this.reloadItems(userTriggered ? ItemsReloadSource.UserTriggeredTagChange : ItemsReloadSource.TagChange)
} }
onFilterEnter = () => { onFilterEnter = () => {

View File

@@ -15,19 +15,28 @@ import {
InternalEventBus, InternalEventBus,
InternalEventPublishStrategy, InternalEventPublishStrategy,
} from '@standardnotes/snjs' } 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 { WebApplication } from '../../Application/Application'
import { FeaturesController } from '../FeaturesController' import { FeaturesController } from '../FeaturesController'
import { AbstractViewController } from '../Abstract/AbstractViewController'
import { destroyAllObjectProperties } from '@/Utils' import { destroyAllObjectProperties } from '@/Utils'
import { isValidFutureSiblings, rootTags, tagSiblings } from './Utils' import { isValidFutureSiblings, rootTags, tagSiblings } from './Utils'
import { AnyTag } from './AnyTagType' import { AnyTag } from './AnyTagType'
import { CrossControllerEvent } from '../CrossControllerEvent' 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<NavigationControllerPersistableValue>
{
tags: SNTag[] = [] tags: SNTag[] = []
smartViews: SmartView[] = [] smartViews: SmartView[] = []
allNotesCount_ = 0 allNotesCount_ = 0
selectedUuid: AnyTag['uuid'] | undefined = undefined
selected_: AnyTag | undefined selected_: AnyTag | undefined
previouslySelected_: AnyTag | undefined previouslySelected_: AnyTag | undefined
editing_: SNTag | SmartView | undefined editing_: SNTag | SmartView | undefined
@@ -63,12 +72,12 @@ export class NavigationController extends AbstractViewController {
allNotesCount: computed, allNotesCount: computed,
setAllNotesCount: action, setAllNotesCount: action,
selected_: observable.ref, selected_: observable,
previouslySelected_: observable.ref, previouslySelected_: observable.ref,
previouslySelected: computed, previouslySelected: computed,
editing_: observable.ref, editing_: observable.ref,
selected: computed, selected: computed,
selectedUuid: computed, selectedUuid: observable,
editingTag: computed, editingTag: computed,
addingSubtagTo: observable, addingSubtagTo: observable,
@@ -94,6 +103,8 @@ export class NavigationController extends AbstractViewController {
setContextMenuMaxHeight: action, setContextMenuMaxHeight: action,
isInFilesView: computed, isInFilesView: computed,
hydrateFromPersistedValue: action,
}) })
this.disposers.push( this.disposers.push(
@@ -103,25 +114,23 @@ export class NavigationController extends AbstractViewController {
this.smartViews = this.application.items.getSmartViews() this.smartViews = this.application.items.getSmartViews()
const currrentSelectedTag = this.selected_ const currentSelectedTag = this.selected_
if (!currrentSelectedTag) {
this.setSelectedTagInstance(this.smartViews[0])
if (!currentSelectedTag) {
return return
} }
const updatedReference = const updatedReference =
FindItem(changed, currrentSelectedTag.uuid) || FindItem(this.smartViews, currrentSelectedTag.uuid) FindItem(changed, currentSelectedTag.uuid) || FindItem(this.smartViews, currentSelectedTag.uuid)
if (updatedReference) { if (updatedReference) {
this.setSelectedTagInstance(updatedReference as AnyTag) this.setSelectedTagInstance(updatedReference as AnyTag)
} }
if (isSystemView(currrentSelectedTag as SmartView)) { if (isSystemView(currentSelectedTag as SmartView)) {
return return
} }
if (FindItem(removed, currrentSelectedTag.uuid)) { if (FindItem(removed, currentSelectedTag.uuid)) {
this.setSelectedTagInstance(this.smartViews[0]) 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() { override deinit() {
@@ -358,7 +413,7 @@ export class NavigationController extends AbstractViewController {
return this.selected_ return this.selected_
} }
public async setSelectedTag(tag: AnyTag | undefined) { public async setSelectedTag(tag: AnyTag | undefined, { userTriggered } = { userTriggered: false }) {
if (tag && tag.conflictOf) { if (tag && tag.conflictOf) {
this.application.mutator this.application.mutator
.changeAndSaveItem(tag, (mutator) => { .changeAndSaveItem(tag, (mutator) => {
@@ -384,7 +439,7 @@ export class NavigationController extends AbstractViewController {
await this.eventBus.publishSync( await this.eventBus.publishSync(
{ {
type: CrossControllerEvent.TagChanged, type: CrossControllerEvent.TagChanged,
payload: { tag, previousTag: this.previouslySelected_ }, payload: { tag, previousTag: this.previouslySelected_, userTriggered: userTriggered },
}, },
InternalEventPublishStrategy.SEQUENCE, InternalEventPublishStrategy.SEQUENCE,
) )
@@ -407,7 +462,10 @@ export class NavigationController extends AbstractViewController {
} }
private setSelectedTagInstance(tag: AnyTag | undefined): void { 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) { public setExpanded(tag: SNTag, expanded: boolean) {
@@ -418,10 +476,6 @@ export class NavigationController extends AbstractViewController {
.catch(console.error) .catch(console.error)
} }
public get selectedUuid(): UuidString | undefined {
return this.selected_?.uuid
}
public get editingTag(): SNTag | SmartView | undefined { public get editingTag(): SNTag | SmartView | undefined {
return this.editing_ return this.editing_
} }

View File

@@ -63,20 +63,6 @@ export class NotesController extends AbstractViewController {
this.itemListController = itemListController this.itemListController = itemListController
this.disposers.push( this.disposers.push(
this.application.streamItems<SNNote>(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(() => { this.application.itemControllerGroup.addActiveControllerChangeObserver(() => {
const controllers = this.application.itemControllerGroup.itemControllers const controllers = this.application.itemControllerGroup.itemControllers
@@ -278,7 +264,7 @@ export class NotesController extends AbstractViewController {
}) })
runInAction(() => { runInAction(() => {
this.selectionController.setSelectedItems({}) this.selectionController.deselectAll()
this.contextMenuOpen = false this.contextMenuOpen = false
}) })
} }
@@ -295,7 +281,7 @@ export class NotesController extends AbstractViewController {
} }
unselectNotes(): void { unselectNotes(): void {
this.selectionController.setSelectedItems({}) this.selectionController.deselectAll()
} }
getSpellcheckStateForNote(note: SNNote) { getSpellcheckStateForNote(note: SNNote) {

View File

@@ -7,17 +7,25 @@ import {
SNNote, SNNote,
UuidString, UuidString,
InternalEventBus, InternalEventBus,
isFile,
} from '@standardnotes/snjs' } 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 { WebApplication } from '../Application/Application'
import { AbstractViewController } from './Abstract/AbstractViewController' import { AbstractViewController } from './Abstract/AbstractViewController'
import { Persistable } from './Abstract/Persistable'
import { CrossControllerEvent } from './CrossControllerEvent'
import { ItemListController } from './ItemList/ItemListController' import { ItemListController } from './ItemList/ItemListController'
type SelectedItems = Record<UuidString, ListableContentItem> export type SelectionControllerPersistableValue = {
selectedUuids: UuidString[]
}
export class SelectedItemsController extends AbstractViewController { export class SelectedItemsController
extends AbstractViewController
implements Persistable<SelectionControllerPersistableValue>
{
lastSelectedItem: ListableContentItem | undefined lastSelectedItem: ListableContentItem | undefined
selectedItems: SelectedItems = {} selectedUuids: Set<UuidString> = observable(new Set<UuidString>())
private itemListController!: ItemListController private itemListController!: ItemListController
override deinit(): void { override deinit(): void {
@@ -29,7 +37,7 @@ export class SelectedItemsController extends AbstractViewController {
super(application, eventBus) super(application, eventBus)
makeObservable(this, { makeObservable(this, {
selectedItems: observable, selectedUuids: observable,
selectedItemsCount: computed, selectedItemsCount: computed,
selectedFiles: computed, selectedFiles: computed,
@@ -37,30 +45,50 @@ export class SelectedItemsController extends AbstractViewController {
firstSelectedItem: computed, firstSelectedItem: computed,
selectItem: action, 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) { public setServicesPostConstruction(itemListController: ItemListController) {
this.itemListController = itemListController this.itemListController = itemListController
this.disposers.push( this.disposers.push(
this.application.streamItems<SNNote | FileItem>( this.application.streamItems<SNNote | FileItem>([ContentType.Note, ContentType.File], ({ removed }) => {
[ContentType.Note, ContentType.File], runInAction(() => {
({ changed, inserted, removed }) => { for (const removedNote of removed) {
runInAction(() => { this.removeFromSelectedUuids(removedNote.uuid)
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
}
}
})
},
),
) )
} }
@@ -69,7 +97,7 @@ export class SelectedItemsController extends AbstractViewController {
} }
get selectedItemsCount(): number { get selectedItemsCount(): number {
return Object.keys(this.selectedItems).length return this.selectedUuids.size
} }
get selectedFiles(): FileItem[] { get selectedFiles(): FileItem[] {
@@ -81,21 +109,31 @@ export class SelectedItemsController extends AbstractViewController {
} }
get firstSelectedItem() { get firstSelectedItem() {
return this.getSelectedItems()[0] return this.application.items.findSureItem(Array.from(this.selectedUuids)[0]) as ListableContentItem
} }
getSelectedItems = <T extends ListableContentItem = ListableContentItem>(contentType?: ContentType): T[] => { getSelectedItems = <T extends ListableContentItem = ListableContentItem>(contentType?: ContentType): T[] => {
return Object.values(this.selectedItems).filter((item) => { const uuids = Array.from(this.selectedUuids)
return !contentType ? true : item.content_type === contentType return uuids.length > 0
}) as T[] ? (uuids
.map((uuid) => this.application.items.findSureItem(uuid))
.filter((item) => {
return !contentType ? true : item.content_type === contentType
}) as T[])
: []
} }
setSelectedItems = (selectedItems: SelectedItems) => { setSelectedUuids = (selectedUuids: Set<UuidString>) => {
this.selectedItems = selectedItems this.selectedUuids = new Set(selectedUuids)
}
private removeFromSelectedUuids = (uuid: UuidString) => {
this.selectedUuids.delete(uuid)
this.setSelectedUuids(this.selectedUuids)
} }
public deselectItem = (item: { uuid: ListableContentItem['uuid'] }): void => { public deselectItem = (item: { uuid: ListableContentItem['uuid'] }): void => {
delete this.selectedItems[item.uuid] this.removeFromSelectedUuids(item.uuid)
if (item.uuid === this.lastSelectedItem?.uuid) { if (item.uuid === this.lastSelectedItem?.uuid) {
this.lastSelectedItem = undefined this.lastSelectedItem = undefined
@@ -103,11 +141,7 @@ export class SelectedItemsController extends AbstractViewController {
} }
public isItemSelected = (item: ListableContentItem): boolean => { public isItemSelected = (item: ListableContentItem): boolean => {
return this.selectedItems[item.uuid] != undefined return this.selectedUuids.has(item.uuid)
}
public updateReferenceOfSelectedItem = (item: ListableContentItem): void => {
this.selectedItems[item.uuid] = item
} }
private selectItemsRange = async ({ private selectItemsRange = async ({
@@ -138,7 +172,7 @@ export class SelectedItemsController extends AbstractViewController {
for (const item of authorizedItems) { for (const item of authorizedItems) {
runInAction(() => { runInAction(() => {
this.selectedItems[item.uuid] = item this.setSelectedUuids(this.selectedUuids.add(item.uuid))
this.lastSelectedItem = item this.lastSelectedItem = item
}) })
} }
@@ -147,7 +181,7 @@ export class SelectedItemsController extends AbstractViewController {
cancelMultipleSelection = () => { cancelMultipleSelection = () => {
this.io.cancelAllKeyboardModifiers() this.io.cancelAllKeyboardModifiers()
const firstSelectedItem = this.getSelectedItems()[0] const firstSelectedItem = this.firstSelectedItem
if (firstSelectedItem) { if (firstSelectedItem) {
this.replaceSelection(firstSelectedItem) this.replaceSelection(firstSelectedItem)
@@ -157,9 +191,8 @@ export class SelectedItemsController extends AbstractViewController {
} }
private replaceSelection = (item: ListableContentItem): void => { private replaceSelection = (item: ListableContentItem): void => {
this.setSelectedItems({ this.deselectAll()
[item.uuid]: item, this.setSelectedUuids(this.selectedUuids.add(item.uuid))
})
this.lastSelectedItem = item this.lastSelectedItem = item
} }
@@ -171,12 +204,25 @@ export class SelectedItemsController extends AbstractViewController {
}) })
} }
private deselectAll = (): void => { deselectAll = (): void => {
this.setSelectedItems({}) this.selectedUuids.clear()
this.setSelectedUuids(this.selectedUuids)
this.lastSelectedItem = undefined 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 ( selectItem = async (
uuid: UuidString, uuid: UuidString,
userTriggered?: boolean, userTriggered?: boolean,
@@ -197,33 +243,25 @@ export class SelectedItemsController extends AbstractViewController {
const isAuthorizedForAccess = await this.application.protections.authorizeItemAccess(item) const isAuthorizedForAccess = await this.application.protections.authorizeItemAccess(item)
if (userTriggered && (hasMeta || hasCtrl)) { if (userTriggered && (hasMeta || hasCtrl)) {
if (this.selectedItems[uuid] && hasMoreThanOneSelected) { if (this.selectedUuids.has(uuid) && hasMoreThanOneSelected) {
delete this.selectedItems[uuid] this.removeFromSelectedUuids(uuid)
} else if (isAuthorizedForAccess) { } else if (isAuthorizedForAccess) {
this.selectedItems[uuid] = item this.setSelectedUuids(this.selectedUuids.add(uuid))
this.lastSelectedItem = item this.lastSelectedItem = item
} }
} else if (userTriggered && hasShift) { } else if (userTriggered && hasShift) {
await this.selectItemsRange({ selectedItem: item }) await this.selectItemsRange({ selectedItem: item })
} else { } else {
const shouldSelectNote = hasMoreThanOneSelected || !this.selectedItems[uuid] const shouldSelectNote = hasMoreThanOneSelected || !this.selectedUuids.has(uuid)
if (shouldSelectNote && isAuthorizedForAccess) { if (shouldSelectNote && isAuthorizedForAccess) {
this.replaceSelection(item) this.replaceSelection(item)
} }
} }
if (this.selectedItemsCount === 1) { await this.openSingleSelectedItem()
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)
}
}
return { 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 = () => { selectNextItem = () => {
const displayableItems = this.itemListController.items const displayableItems = this.itemListController.items

View File

@@ -11,13 +11,15 @@ import {
ItemCounterInterface, ItemCounterInterface,
ItemCounter, ItemCounter,
SubscriptionClientInterface, SubscriptionClientInterface,
InternalEventHandlerInterface,
InternalEventInterface,
} from '@standardnotes/snjs' } from '@standardnotes/snjs'
import { action, makeObservable, observable } from 'mobx' import { action, makeObservable, observable } from 'mobx'
import { ActionsMenuController } from './ActionsMenuController' import { ActionsMenuController } from './ActionsMenuController'
import { FeaturesController } from './FeaturesController' import { FeaturesController } from './FeaturesController'
import { FilesController } from './FilesController' import { FilesController } from './FilesController'
import { NotesController } from './NotesController' import { NotesController } from './NotesController'
import { ItemListController } from './ItemList/ItemListController' import { ItemListController, ItemListControllerPersistableValue } from './ItemList/ItemListController'
import { NoAccountWarningController } from './NoAccountWarningController' import { NoAccountWarningController } from './NoAccountWarningController'
import { PreferencesController } from './PreferencesController' import { PreferencesController } from './PreferencesController'
import { PurchaseFlowController } from './PurchaseFlow/PurchaseFlowController' import { PurchaseFlowController } from './PurchaseFlow/PurchaseFlowController'
@@ -25,14 +27,16 @@ import { QuickSettingsController } from './QuickSettingsController'
import { SearchOptionsController } from './SearchOptionsController' import { SearchOptionsController } from './SearchOptionsController'
import { SubscriptionController } from './Subscription/SubscriptionController' import { SubscriptionController } from './Subscription/SubscriptionController'
import { SyncStatusController } from './SyncStatusController' import { SyncStatusController } from './SyncStatusController'
import { NavigationController } from './Navigation/NavigationController' import { NavigationController, NavigationControllerPersistableValue } from './Navigation/NavigationController'
import { FilePreviewModalController } from './FilePreviewModalController' import { FilePreviewModalController } from './FilePreviewModalController'
import { SelectedItemsController } from './SelectedItemsController' import { SelectedItemsController, SelectionControllerPersistableValue } from './SelectedItemsController'
import { HistoryModalController } from './NoteHistory/HistoryModalController' import { HistoryModalController } from './NoteHistory/HistoryModalController'
import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane' import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane'
import { LinkingController } from './LinkingController' 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 readonly enableUnfinishedFeatures: boolean = window?.enabledUnfinishedFeatures
private unsubAppEventObserver!: () => void private unsubAppEventObserver!: () => void
@@ -65,10 +69,16 @@ export class ViewControllerManager {
private eventBus: InternalEventBus private eventBus: InternalEventBus
private itemCounter: ItemCounterInterface private itemCounter: ItemCounterInterface
private subscriptionManager: SubscriptionClientInterface private subscriptionManager: SubscriptionClientInterface
private persistenceService: PersistenceService
constructor(public application: WebApplication, private device: WebOrDesktopDeviceInterface) { constructor(public application: WebApplication, private device: WebOrDesktopDeviceInterface) {
this.eventBus = new InternalEventBus() 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.itemCounter = new ItemCounter()
this.subscriptionManager = application.subscriptions this.subscriptionManager = application.subscriptions
@@ -173,6 +183,9 @@ export class ViewControllerManager {
;(this.quickSettingsMenuController as unknown) = undefined ;(this.quickSettingsMenuController as unknown) = undefined
;(this.syncStatusController as unknown) = undefined ;(this.syncStatusController as unknown) = undefined
this.persistenceService.deinit()
;(this.persistenceService as unknown) = undefined
this.actionsMenuController.reset() this.actionsMenuController.reset()
;(this.actionsMenuController as unknown) = undefined ;(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<void> {
if (event.type === CrossControllerEvent.HydrateFromPersistedValues) {
this.hydrateFromPersistedValues(event.payload as MasterPersistedValue | undefined)
} else if (event.type === CrossControllerEvent.RequestValuePersistence) {
this.persistValues()
}
}
} }