internal: incomplete vault systems behind feature flag (#2340)

This commit is contained in:
Mo
2023-06-30 09:01:56 -05:00
committed by GitHub
parent d16e401bb9
commit b032eb9c9b
638 changed files with 20321 additions and 4813 deletions

View File

@@ -77,14 +77,14 @@ const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
const selectComponent = useCallback(
async (component: SNComponent, note: SNNote) => {
if (component.conflictOf) {
void application.mutator.changeAndSaveItem(component, (mutator) => {
void application.changeAndSaveItem(component, (mutator) => {
mutator.conflictOf = undefined
})
}
await application.getViewControllerManager().itemListController.insertCurrentIfTemplate()
await application.mutator.changeAndSaveItem(note, (mutator) => {
await application.changeAndSaveItem(note, (mutator) => {
const noteMutator = mutator as NoteMutator
noteMutator.noteType = component.noteType
noteMutator.editorIdentifier = component.identifier
@@ -101,7 +101,7 @@ const ChangeEditorMenu: FunctionComponent<ChangeEditorMenuProps> = ({
reloadFont(application.getPreference(PrefKey.EditorMonospaceEnabled))
await application.mutator.changeAndSaveItem(note, (mutator) => {
await application.changeAndSaveItem(note, (mutator) => {
const noteMutator = mutator as NoteMutator
noteMutator.noteType = item.noteType
noteMutator.editorIdentifier = undefined

View File

@@ -42,12 +42,12 @@ const ChangeMultipleMenu = ({ application, notes, setDisableClickOutside }: Prop
const selectComponent = useCallback(
async (component: SNComponent, note: SNNote) => {
if (component.conflictOf) {
void application.mutator.changeAndSaveItem(component, (mutator) => {
void application.changeAndSaveItem(component, (mutator) => {
mutator.conflictOf = undefined
})
}
await application.mutator.changeAndSaveItem(note, (mutator) => {
await application.changeAndSaveItem(note, (mutator) => {
const noteMutator = mutator as NoteMutator
noteMutator.noteType = component.noteType
noteMutator.editorIdentifier = component.identifier
@@ -58,7 +58,7 @@ const ChangeMultipleMenu = ({ application, notes, setDisableClickOutside }: Prop
const selectNonComponent = useCallback(
async (item: EditorMenuItem, note: SNNote) => {
await application.mutator.changeAndSaveItem(note, (mutator) => {
await application.changeAndSaveItem(note, (mutator) => {
const noteMutator = mutator as NoteMutator
noteMutator.noteType = item.noteType
noteMutator.editorIdentifier = undefined

View File

@@ -64,6 +64,7 @@ const ClippedNoteView = ({
setIsDiscarding(true)
application.mutator
.deleteItem(note)
.then(() => application.sync.sync())
.then(() => {
if (isFirefoxPopup) {
window.close()
@@ -73,7 +74,7 @@ const ClippedNoteView = ({
.catch(console.error)
.finally(() => setIsDiscarding(false))
}
}, [application.mutator, clearClip, isFirefoxPopup, note])
}, [application.mutator, application.sync, clearClip, isFirefoxPopup, note])
return (
<div className="">

View File

@@ -217,7 +217,7 @@ const ClipperView = ({
references: [],
})
const insertedNote = await application.items.insertItem(note)
const insertedNote = await application.mutator.insertItem(note)
if (defaultTagRef.current) {
await application.linkingController.linkItems(insertedNote, defaultTagRef.current)
@@ -237,6 +237,7 @@ const ClipperView = ({
}, [
application.items,
application.linkingController,
application.mutator,
application.sync,
clipPayload,
defaultTagRef,

View File

@@ -13,6 +13,7 @@ import { getIconForFileType } from '@/Utils/Items/Icons/getIconForFileType'
import { useApplication } from '../ApplicationProvider'
import { PaneLayout } from '@/Controllers/PaneController/PaneLayout'
import ListItemFlagIcons from './ListItemFlagIcons'
import ListItemVaultInfo from './ListItemVaultInfo'
const FileListItemCard: FunctionComponent<DisplayableListItemProps<FileItem>> = ({
filesController,
@@ -103,6 +104,7 @@ const FileListItemCard: FunctionComponent<DisplayableListItemProps<FileItem>> =
<ListItemMetadata item={file} hideDate={hideDate} sortBy={sortBy} />
<ListItemTags hideTags={hideTags} tags={tags} />
<ListItemConflictIndicator item={file} />
<ListItemVaultInfo item={file} />
</div>
<ListItemFlagIcons className="p-4" item={file} isFileBackedUp={!!backupInfo} />
</div>

View File

@@ -14,6 +14,7 @@ import { getIconForFileType } from '@/Utils/Items/Icons/getIconForFileType'
import { useApplication } from '../ApplicationProvider'
import Icon from '../Icon/Icon'
import { PaneLayout } from '@/Controllers/PaneController/PaneLayout'
import ListItemVaultInfo from './ListItemVaultInfo'
const FileListItemCard: FunctionComponent<DisplayableListItemProps<FileItem>> = ({
filesController,
@@ -106,6 +107,7 @@ const FileListItemCard: FunctionComponent<DisplayableListItemProps<FileItem>> =
<ListItemMetadata item={file} hideDate={hideDate} sortBy={sortBy} />
<ListItemTags hideTags={hideTags} tags={tags} />
<ListItemConflictIndicator item={file} />
<ListItemVaultInfo item={file} />
</div>
</div>
<div

View File

@@ -14,6 +14,7 @@ import { FilesController } from '@/Controllers/FilesController'
import SearchButton from './SearchButton'
import { ItemListController } from '@/Controllers/ItemList/ItemListController'
import { PaneController } from '@/Controllers/PaneController/PaneController'
import ListItemVaultInfo from '../ListItemVaultInfo'
type Props = {
application: WebApplication
@@ -162,15 +163,16 @@ const ContentListHeader = ({
)}
/>
)}
<div className="flex min-w-0 flex-grow flex-col break-words">
<div className="mr-2 flex min-w-0 flex-col break-words">
<div className="text-2xl font-semibold text-text md:text-lg">{panelTitle}</div>
{showSyncSubtitle && <div className="-mt-1 text-xs text-passive-0 md:mt-0">{syncSubtitle}</div>}
{optionsSubtitle && <div className="text-xs text-passive-0">{optionsSubtitle}</div>}
</div>
<ListItemVaultInfo item={selectedTag} />
</div>
</div>
)
}, [optionsSubtitle, showSyncSubtitle, icon, panelTitle, syncSubtitle])
}, [optionsSubtitle, selectedTag, showSyncSubtitle, icon, panelTitle, syncSubtitle])
const PhoneAndDesktopLayout = useMemo(() => {
return (

View File

@@ -181,7 +181,7 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
} else if (isSystemTag) {
await changeSystemViewPreferences(properties)
} else {
await application.mutator.changeAndSaveItem<TagMutator>(selectedTag, (mutator) => {
await application.changeAndSaveItem<TagMutator>(selectedTag, (mutator) => {
mutator.preferences = {
...mutator.preferences,
...properties,
@@ -189,7 +189,7 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
})
}
},
[currentMode, isSystemTag, changeGlobalPreferences, changeSystemViewPreferences, application.mutator, selectedTag],
[currentMode, isSystemTag, changeGlobalPreferences, changeSystemViewPreferences, application, selectedTag],
)
const resetTagPreferences = useCallback(async () => {
@@ -202,7 +202,7 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
return
}
void application.mutator.changeAndSaveItem<TagMutator>(selectedTag, (mutator) => {
void application.changeAndSaveItem<TagMutator>(selectedTag, (mutator) => {
mutator.preferences = undefined
})
}, [application, isSystemTag, reloadPreferences, selectedTag])

View File

@@ -0,0 +1,46 @@
import { FunctionComponent } from 'react'
import { useApplication } from '../ApplicationProvider'
import Icon from '../Icon/Icon'
import { DecryptedItemInterface } from '@standardnotes/snjs'
import VaultNameBadge from '../Vaults/VaultNameBadge'
import { featureTrunkVaultsEnabled } from '@/FeatureTrunk'
type Props = {
item: DecryptedItemInterface
}
const ListItemVaultInfo: FunctionComponent<Props> = ({ item }) => {
const application = useApplication()
if (!featureTrunkVaultsEnabled()) {
return null
}
if (application.items.isTemplateItem(item)) {
return null
}
const vault = application.vaults.getItemVault(item)
if (!vault) {
return null
}
const sharedByContact = application.sharedVaults.getItemSharedBy(item)
return (
<div className="mt-0.5 flex flex-wrap items-center gap-2">
<VaultNameBadge vault={vault} />
{sharedByContact && (
<div title="Shared by contact" className={'mt-2 rounded bg-info py-1 px-1.5 text-neutral-contrast'}>
<span className="flex items-center" title="Shared by contact">
<Icon ariaLabel="Shared by contact" type="archive" className="mr-1 text-info-contrast" size="medium" />
<div className="text-center text-xs font-bold">{sharedByContact?.name}</div>
</span>
</div>
)}
</div>
)
}
export default ListItemVaultInfo

View File

@@ -13,6 +13,7 @@ import { ListItemTitle } from './ListItemTitle'
import { log, LoggingDomain } from '@/Logging'
import { classNames } from '@standardnotes/utils'
import { getIconAndTintForNoteType } from '@/Utils/Items/Icons/getIconAndTintForNoteType'
import ListItemVaultInfo from './ListItemVaultInfo'
import { NoteDragDataFormat } from '../Tags/DragNDrop'
import { MutuallyExclusiveMediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
@@ -143,6 +144,7 @@ const NoteListItem: FunctionComponent<DisplayableListItemProps<SNNote>> = ({
<ListItemMetadata item={item} hideDate={hideDate} sortBy={sortBy} />
<ListItemTags hideTags={hideTags} tags={tags} />
<ListItemConflictIndicator item={item} />
<ListItemVaultInfo item={item} />
</div>
<ListItemFlagIcons className="p-4" item={item} hasFiles={hasFiles} hasBorder={hasOffsetBorder} />
</div>

View File

@@ -39,6 +39,7 @@ import { HistoryModalController } from '@/Controllers/NoteHistory/HistoryModalCo
import { useItemLinks } from '@/Hooks/useItemLinks'
import { ItemLink } from '@/Utils/Items/Search/ItemLink'
import { ItemListController } from '@/Controllers/ItemList/ItemListController'
import ListItemVaultInfo from '../ContentListView/ListItemVaultInfo'
const ContextMenuCell = ({
items,
@@ -213,6 +214,7 @@ const ItemNameCell = ({ item, hideIcon }: { item: DecryptedItemInterface; hideIc
)}
</span>
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium">{item.title}</span>
<ListItemVaultInfo item={item} />
{item.protected && (
<span className="flex items-center" title="File is protected">
<Icon ariaLabel="File is protected" type="lock-filled" className="h-3.5 w-3.5 text-passive-1" size="custom" />
@@ -245,7 +247,7 @@ const AttachedToCell = ({ item }: { item: DecryptedItemInterface }) => {
link={allLinks[0]}
key={allLinks[0].id}
unlinkItem={async (itemToUnlink) => {
void application.items.unlinkItems(item, itemToUnlink)
void application.mutator.unlinkItems(item, itemToUnlink)
}}
isBidirectional={false}
/>
@@ -312,7 +314,7 @@ const ContentTableView = ({
return
}
await application.mutator.changeAndSaveItem<TagMutator>(selectedTag, (mutator) => {
await application.changeAndSaveItem<TagMutator>(selectedTag, (mutator) => {
mutator.preferences = {
...mutator.preferences,
sortBy,

View File

@@ -15,6 +15,9 @@ import AddTagOption from '../NotesOptions/AddTagOption'
import { MenuItemIconSize } from '@/Constants/TailwindClassNames'
import { LinkingController } from '@/Controllers/LinkingController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import AddToVaultMenuOption from '../Vaults/AddToVaultMenuOption'
import { iconClass } from '../NotesOptions/ClassNames'
import { featureTrunkVaultsEnabled } from '@/FeatureTrunk'
type Props = {
closeMenu: () => void
@@ -90,6 +93,7 @@ const FileMenuOptions: FunctionComponent<Props> = ({
) : null}
</>
)}
{featureTrunkVaultsEnabled() && <AddToVaultMenuOption iconClassName={iconClass} items={selectedFiles} />}
<AddTagOption
navigationController={navigationController}
linkingController={linkingController}

View File

@@ -95,14 +95,14 @@ const FilePreviewModal = observer(({ application, viewControllerManager }: Props
if (renameInputRef.current) {
const newName = renameInputRef.current.value
if (newName && newName !== currentFile.name) {
await application.items.renameFile(currentFile, newName)
await application.mutator.renameFile(currentFile, newName)
setIsRenaming(false)
setCurrentFile(application.items.findSureItem(currentFile.uuid))
return
}
setIsRenaming(false)
}
}, [application.items, currentFile, setCurrentFile])
}, [application.items, application.mutator, currentFile, setCurrentFile])
const isMobileScreen = useMediaQuery(MutuallyExclusiveMediaQueryBreakpoints.sm)

View File

@@ -34,7 +34,7 @@ const FileViewWithoutProtection = ({ application, viewControllerManager, file }:
const syncDebounceMs = shouldNotDebounce ? SyncTimeoutNoDebounceMs : SyncTimeoutDebounceMs
syncTimeoutRef.current = window.setTimeout(async () => {
await application.items.renameFile(file, event.target.value)
await application.mutator.renameFile(file, event.target.value)
void application.sync.sync()
}, syncDebounceMs)
},

View File

@@ -21,6 +21,7 @@ import AccountMenuButton from './AccountMenuButton'
import StyledTooltip from '../StyledTooltip/StyledTooltip'
import UpgradeNow from './UpgradeNow'
import PreferencesButton from './PreferencesButton'
import VaultSelectionButton from './VaultSelectionButton'
type Props = {
application: WebApplication
@@ -37,6 +38,7 @@ type State = {
newUpdateAvailable: boolean
showAccountMenu: boolean
showQuickSettingsMenu: boolean
showVaultSelectionMenu: boolean
offline: boolean
hasError: boolean
arbitraryStatusMessage?: string
@@ -64,6 +66,7 @@ class Footer extends AbstractComponent<Props, State> {
newUpdateAvailable: false,
showAccountMenu: false,
showQuickSettingsMenu: false,
showVaultSelectionMenu: false,
}
this.webEventListenerDestroyer = props.application.addWebEventObserver((event, data) => {
@@ -125,6 +128,7 @@ class Footer extends AbstractComponent<Props, State> {
showBetaWarning: showBetaWarning,
showAccountMenu: this.viewControllerManager.accountMenuController.show,
showQuickSettingsMenu: this.viewControllerManager.quickSettingsMenuController.open,
showVaultSelectionMenu: this.viewControllerManager.vaultSelectionController.open,
})
})
}
@@ -296,6 +300,10 @@ class Footer extends AbstractComponent<Props, State> {
this.viewControllerManager.quickSettingsMenuController.toggle()
}
vaultSelectionClickHandler = () => {
this.viewControllerManager.vaultSelectionController.toggle()
}
syncResolutionClickHandler = () => {
this.setState({
showSyncResolution: !this.state.showSyncResolution,
@@ -367,9 +375,11 @@ class Footer extends AbstractComponent<Props, State> {
viewControllerManager={this.viewControllerManager}
/>
</div>
<div className="relative z-footer-bar-item select-none">
<PreferencesButton openPreferences={this.openPreferences} />
</div>
<div className="relative z-footer-bar-item select-none">
<QuickSettingsButton
isOpen={this.state.showQuickSettingsMenu}
@@ -378,6 +388,14 @@ class Footer extends AbstractComponent<Props, State> {
quickSettingsMenuController={this.viewControllerManager.quickSettingsMenuController}
/>
</div>
<div className="relative z-footer-bar-item ml-1.5 select-none">
<VaultSelectionButton
isOpen={this.state.showVaultSelectionMenu}
toggleMenu={this.vaultSelectionClickHandler}
controller={this.viewControllerManager.vaultSelectionController}
/>
</div>
<UpgradeNow
application={this.application}
featuresController={this.viewControllerManager.featuresController}

View File

@@ -29,7 +29,7 @@ const QuickSettingsButton = ({ application, isOpen, toggleMenu, quickSettingsMen
.getDisplayableComponents()
.find((theme) => theme.package_info.identifier === FeatureIdentifier.DarkTheme) as SNTheme | undefined
if (darkTheme) {
void application.mutator.toggleTheme(darkTheme)
void application.componentManager.toggleTheme(darkTheme.uuid)
}
},
})

View File

@@ -0,0 +1,62 @@
import { classNames } from '@standardnotes/utils'
import { useRef } from 'react'
import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover'
import StyledTooltip from '../StyledTooltip/StyledTooltip'
import { VaultSelectionMenuController } from '@/Controllers/VaultSelectionMenuController'
import VaultSelectionMenu from '../VaultSelectionMenu/VaultSelectionMenu'
import { useApplication } from '../ApplicationProvider'
import { observer } from 'mobx-react-lite'
import { featureTrunkVaultsEnabled } from '@/FeatureTrunk'
type Props = {
isOpen: boolean
toggleMenu: () => void
controller: VaultSelectionMenuController
}
const VaultSelectionButton = ({ isOpen, toggleMenu, controller }: Props) => {
const application = useApplication()
const buttonRef = useRef<HTMLButtonElement>(null)
const exclusivelyShownVault = application.vaultDisplayService.exclusivelyShownVault
if (!featureTrunkVaultsEnabled()) {
return null
}
return (
<>
<StyledTooltip label="Open vault selection menu">
<button onClick={toggleMenu} className="flex h-full cursor-pointer items-center justify-center" ref={buttonRef}>
<div className="flex items-center">
<Icon
type="safe-square"
className={classNames(
isOpen ? 'text-info' : exclusivelyShownVault ? 'text-success' : '',
'rounded hover:text-info',
)}
/>
{exclusivelyShownVault && (
<div className={classNames('ml-1 text-xs font-bold', isOpen && 'text-info')}>
{exclusivelyShownVault.name}
</div>
)}
</div>
</button>
</StyledTooltip>
<Popover
title="Vault options"
togglePopover={toggleMenu}
anchorElement={buttonRef.current}
open={isOpen}
side="top"
align="start"
className="py-2"
>
<VaultSelectionMenu controller={controller} />
</Popover>
</>
)
}
export default observer(VaultSelectionButton)

View File

@@ -80,6 +80,7 @@ export const IconNameToSvgMapping = {
check: icons.CheckIcon,
close: icons.CloseIcon,
code: icons.CodeIcon,
comment: icons.FeedbackIcon,
copy: icons.CopyIcon,
dashboard: icons.DashboardIcon,
diamond: icons.DiamondIcon,
@@ -92,6 +93,7 @@ export const IconNameToSvgMapping = {
file: icons.FileIcon,
folder: icons.FolderIcon,
gkeep: icons.GoogleKeepIcon,
group: icons.GroupIcon,
hashtag: icons.HashtagIcon,
help: icons.HelpIcon,
history: icons.HistoryIcon,

View File

@@ -0,0 +1,46 @@
import { FunctionComponent } from 'react'
import Icon from '../Icon/Icon'
import { useApplication } from '../ApplicationProvider'
import { DecryptedItemInterface } from '@standardnotes/snjs'
import { featureTrunkVaultsEnabled } from '@/FeatureTrunk'
type Props = {
item: DecryptedItemInterface
}
const CollaborationInfoHUD: FunctionComponent<Props> = ({ item }) => {
const application = useApplication()
if (!featureTrunkVaultsEnabled()) {
return null
}
if (application.items.isTemplateItem(item)) {
return null
}
const vault = application.vaults.getItemVault(item)
if (!vault) {
return null
}
const lastEditedBy = application.sharedVaults.getItemLastEditedBy(item)
return (
<div className="flex flex-wrap items-start gap-2">
<div title="Vault name" className={'flex rounded bg-success py-1 px-1.5 text-success-contrast'}>
<Icon ariaLabel="Shared in vault" type="safe-square" className="mr-1 text-info-contrast" size="medium" />
<span className="mr-auto overflow-hidden text-ellipsis text-xs">{vault.name}</span>
</div>
{lastEditedBy && (
<div title="Last edited by" className={'flex rounded bg-info py-1 px-1.5 text-info-contrast'}>
<Icon ariaLabel="Shared by" type="pencil" className="mr-1 text-info-contrast" size="medium" />
<span className="mr-auto overflow-hidden text-ellipsis text-xs">{lastEditedBy?.name}</span>
</div>
)}
</div>
)
}
export default CollaborationInfoHUD

View File

@@ -5,10 +5,11 @@ import {
SNComponentManager,
SNComponent,
SNTag,
ItemsClientInterface,
SNNote,
Deferred,
SyncServiceInterface,
ItemManagerInterface,
MutatorClientInterface,
} from '@standardnotes/snjs'
import { FeatureIdentifier, NoteType } from '@standardnotes/features'
import { NoteViewController } from './NoteViewController'
@@ -24,7 +25,9 @@ describe('note view controller', () => {
application.noAccount = jest.fn().mockReturnValue(false)
application.isNativeMobileWeb = jest.fn().mockReturnValue(false)
Object.defineProperty(application, 'items', { value: {} as jest.Mocked<ItemsClientInterface> })
const items = {} as jest.Mocked<ItemManagerInterface>
items.createTemplateItem = jest.fn().mockReturnValue({} as SNNote)
Object.defineProperty(application, 'items', { value: items })
Object.defineProperty(application, 'sync', { value: {} as jest.Mocked<SyncServiceInterface> })
application.sync.sync = jest.fn().mockReturnValue(Promise.resolve())
@@ -33,8 +36,7 @@ describe('note view controller', () => {
componentManager.legacyGetDefaultEditor = jest.fn()
Object.defineProperty(application, 'componentManager', { value: componentManager })
const mutator = {} as jest.Mocked<MutatorService>
mutator.createTemplateItem = jest.fn().mockReturnValue({} as SNNote)
const mutator = {} as jest.Mocked<MutatorClientInterface>
Object.defineProperty(application, 'mutator', { value: mutator })
})
@@ -44,7 +46,7 @@ describe('note view controller', () => {
const controller = new NoteViewController(application)
await controller.initialize()
expect(application.mutator.createTemplateItem).toHaveBeenCalledWith(
expect(application.items.createTemplateItem).toHaveBeenCalledWith(
ContentType.Note,
expect.objectContaining({ noteType: NoteType.Plain }),
expect.anything(),
@@ -65,7 +67,7 @@ describe('note view controller', () => {
const controller = new NoteViewController(application)
await controller.initialize()
expect(application.mutator.createTemplateItem).toHaveBeenCalledWith(
expect(application.items.createTemplateItem).toHaveBeenCalledWith(
ContentType.Note,
expect.objectContaining({ noteType: NoteType.Markdown }),
expect.anything(),
@@ -80,13 +82,13 @@ describe('note view controller', () => {
} as jest.Mocked<SNTag>
application.items.findItem = jest.fn().mockReturnValue(tag)
application.items.addTagToNote = jest.fn()
application.mutator.addTagToNote = jest.fn()
const controller = new NoteViewController(application, undefined, { tag: tag.uuid })
await controller.initialize()
expect(controller['defaultTag']).toEqual(tag)
expect(application.items.addTagToNote).toHaveBeenCalledWith(expect.anything(), tag, expect.anything())
expect(application.mutator.addTagToNote).toHaveBeenCalledWith(expect.anything(), tag, expect.anything())
})
it('should wait until item finishes saving locally before deiniting', async () => {

View File

@@ -1,6 +1,14 @@
import { WebApplication } from '@/Application/WebApplication'
import { noteTypeForEditorIdentifier } from '@standardnotes/features'
import { SNNote, SNTag, NoteContent, DecryptedItemInterface, PayloadEmitSource, PrefKey } from '@standardnotes/models'
import {
SNNote,
SNTag,
NoteContent,
DecryptedItemInterface,
PayloadEmitSource,
PrefKey,
PayloadVaultOverrides,
} from '@standardnotes/models'
import { UuidString } from '@standardnotes/snjs'
import { removeFromArray } from '@standardnotes/utils'
import { ContentType } from '@standardnotes/common'
@@ -90,7 +98,7 @@ export class NoteViewController implements ItemViewControllerInterface {
const noteType = noteTypeForEditorIdentifier(editorIdentifier)
const note = this.application.mutator.createTemplateItem<NoteContent, SNNote>(
const note = this.application.items.createTemplateItem<NoteContent, SNNote>(
ContentType.Note,
{
text: '',
@@ -101,6 +109,7 @@ export class NoteViewController implements ItemViewControllerInterface {
},
{
created_at: this.templateNoteOptions?.createdAt || new Date(),
...PayloadVaultOverrides(this.templateNoteOptions?.vault),
},
)
@@ -110,7 +119,7 @@ export class NoteViewController implements ItemViewControllerInterface {
if (this.defaultTagUuid) {
const tag = this.application.items.findItem(this.defaultTagUuid) as SNTag
await this.application.items.addTagToNote(note, tag, addTagHierarchy)
await this.application.mutator.addTagToNote(note, tag, addTagHierarchy)
}
this.notifyObservers(this.item, PayloadEmitSource.InitialObserverRegistrationPush)

View File

@@ -1,8 +1,9 @@
import { UuidString } from '@standardnotes/snjs'
import { UuidString, VaultListingInterface } from '@standardnotes/snjs'
export type TemplateNoteViewControllerOptions = {
title?: string
tag?: UuidString
vault?: VaultListingInterface
createdAt?: Date
autofocusBehavior?: TemplateNoteViewAutofocusBehavior
}

View File

@@ -65,12 +65,13 @@ const NoteConflictResolutionModal = ({
async (note: SNNote) => {
await application.mutator
.deleteItem(note)
.then(() => application.sync.sync())
.catch(console.error)
.then(() => {
setSelectedVersions([allVersions[0].uuid])
})
},
[allVersions, application.mutator],
[allVersions, application.mutator, application.sync],
)
const [selectedAction, setSelectionAction] = useState<ConflictAction>('move-to-trash')

View File

@@ -45,6 +45,7 @@ import { SuperEditorContentId } from '../SuperEditor/Constants'
import { NoteViewController } from './Controller/NoteViewController'
import { PlainEditor, PlainEditorInterface } from './PlainEditor/PlainEditor'
import { EditorMargins, EditorMaxWidths } from '../EditorWidthSelectionModal/EditorWidths'
import CollaborationInfoHUD from './CollaborationInfoHUD'
import Button from '../Button/Button'
import ModalOverlay from '../Modal/ModalOverlay'
import NoteConflictResolutionModal from './NoteConflictResolutionModal/NoteConflictResolutionModal'
@@ -74,7 +75,7 @@ type State = {
monospaceFont?: boolean
plainEditorFocused?: boolean
paneGestureEnabled?: boolean
noteLastEditedByUuid?: string
updateSavingIndicator?: boolean
editorFeatureIdentifier?: string
noteType?: NoteType
@@ -270,6 +271,12 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
})
}
if (note.last_edited_by_uuid !== this.state.noteLastEditedByUuid) {
this.setState({
noteLastEditedByUuid: note.last_edited_by_uuid,
})
}
if (note.locked !== this.state.noteLocked) {
this.setState({
noteLocked: note.locked,
@@ -651,7 +658,10 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
}
performNoteDeletion(note: SNNote) {
this.application.mutator.deleteItem(note).catch(console.error)
this.application.mutator
.deleteItem(note)
.then(() => this.application.sync.sync())
.catch(console.error)
}
onPanelResizeFinish = async (width: number, left: number, isMaxWidth: boolean) => {
@@ -897,6 +907,7 @@ class NoteView extends AbstractComponent<NoteViewProps, State> {
)}
{renderHeaderOptions && (
<div className="note-view-options-buttons flex items-center gap-3">
<CollaborationInfoHUD item={this.note} />
<LinkedItemsButton
filesController={this.viewControllerManager.filesController}
linkingController={this.viewControllerManager.linkingController}

View File

@@ -24,7 +24,7 @@ const NoteViewFileDropTarget = ({ note, linkingController, noteViewElement, file
tooltipText: 'Drop your files to upload and link them to the current note',
callback: async (uploadedFile) => {
await linkingController.linkItems(note, uploadedFile)
void application.mutator.changeAndSaveItem(uploadedFile, (mutator) => {
void application.changeAndSaveItem(uploadedFile, (mutator) => {
mutator.protected = note.protected
})
filesController.notifyObserversOfUploadedFileLinkingToCurrentNote(uploadedFile.uuid)
@@ -37,7 +37,7 @@ const NoteViewFileDropTarget = ({ note, linkingController, noteViewElement, file
removeDragTarget(target)
}
}
}, [addDragTarget, linkingController, note, noteViewElement, removeDragTarget, filesController, application.mutator])
}, [addDragTarget, linkingController, note, noteViewElement, removeDragTarget, filesController, application])
return isDraggingFiles ? (
// Required to block drag events to editor iframe

View File

@@ -36,14 +36,14 @@ export const ReadonlyNoteContent = ({
return undefined
}
const templateNoteForRevision = application.mutator.createTemplateItem(ContentType.Note, note.content) as SNNote
const templateNoteForRevision = application.items.createTemplateItem(ContentType.Note, note.content) as SNNote
const componentViewer = application.componentManager.createComponentViewer(editorForCurrentNote)
componentViewer.setReadonly(true)
componentViewer.lockReadonly = true
componentViewer.overrideContextItem = templateNoteForRevision
return componentViewer
}, [application.componentManager, application.mutator, note])
}, [application.componentManager, application.items, note])
useEffect(() => {
return () => {

View File

@@ -37,6 +37,8 @@ import ModalOverlay from '../Modal/ModalOverlay'
import SuperExportModal from './SuperExportModal'
import { useApplication } from '../ApplicationProvider'
import { MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
import AddToVaultMenuOption from '../Vaults/AddToVaultMenuOption'
import { featureTrunkVaultsEnabled } from '@/FeatureTrunk'
const iconSize = MenuItemIconSize
const iconClassDanger = `text-danger mr-2 ${iconSize}`
@@ -144,8 +146,9 @@ const NotesOptions = ({
const duplicateSelectedItems = useCallback(async () => {
await Promise.all(notes.map((note) => application.mutator.duplicateItem(note).catch(console.error)))
void application.sync.sync()
closeMenuAndToggleNotesList()
}, [application.mutator, closeMenuAndToggleNotesList, notes])
}, [application.mutator, application.sync, closeMenuAndToggleNotesList, notes])
const openRevisionHistoryModal = useCallback(() => {
historyModalController.openModal(notesController.firstSelectedNote)
@@ -240,6 +243,9 @@ const NotesOptions = ({
</>
)}
<HorizontalSeparator classes="my-2" />
{featureTrunkVaultsEnabled() && <AddToVaultMenuOption iconClassName={iconClass} items={notes} />}
{navigationController.tagsCount > 0 && (
<AddTagOption
iconClassName={iconClass}

View File

@@ -10,6 +10,7 @@ import Listed from './Panes/Listed/Listed'
import HelpAndFeedback from './Panes/HelpFeedback'
import { PreferencesProps } from './PreferencesProps'
import WhatsNew from './Panes/WhatsNew/WhatsNew'
import Vaults from './Panes/Vaults/Vaults'
const PaneSelector: FunctionComponent<PreferencesProps & { menu: PreferencesMenu }> = ({
menu,
@@ -40,6 +41,8 @@ const PaneSelector: FunctionComponent<PreferencesProps & { menu: PreferencesMenu
application={application}
/>
)
case 'vaults':
return <Vaults />
case 'backups':
return <Backups application={application} viewControllerManager={viewControllerManager} />
case 'listed':

View File

@@ -109,7 +109,7 @@ const DataBackups = ({ application, viewControllerManager }: Props) => {
const performImport = async (data: BackupFile) => {
setIsImportDataLoading(true)
const result = await application.mutator.importData(data)
const result = await application.importData(data)
setIsImportDataLoading(false)

View File

@@ -34,7 +34,7 @@ const PackageEntry: FunctionComponent<PackageEntryProps> = ({ application, exten
const toggleOfflineOnly = () => {
const newOfflineOnly = !offlineOnly
setOfflineOnly(newOfflineOnly)
application.mutator
application
.changeAndSaveItem<ComponentMutator>(extension, (mutator) => {
mutator.offlineOnly = newOfflineOnly
})
@@ -49,7 +49,7 @@ const PackageEntry: FunctionComponent<PackageEntryProps> = ({ application, exten
const changeExtensionName = (newName: string) => {
setExtensionName(newName)
application.mutator
application
.changeAndSaveItem<ComponentMutator>(extension, (mutator) => {
mutator.name = newName
})

View File

@@ -53,6 +53,7 @@ const PackagesPreferencesSection: FunctionComponent<Props> = ({
.then(async (shouldRemove: boolean) => {
if (shouldRemove) {
await application.mutator.deleteItem(extension)
void application.sync.sync()
setExtensions(loadExtensions(application))
}
})

View File

@@ -12,7 +12,9 @@ type Props = {
export const ShouldPersistNoteStateKey = 'ShouldPersistNoteState'
const Persistence = ({ application }: Props) => {
const [shouldPersistNoteState, setShouldPersistNoteState] = useState(application.getValue(ShouldPersistNoteStateKey))
const [shouldPersistNoteState, setShouldPersistNoteState] = useState(
application.getValue<boolean>(ShouldPersistNoteStateKey),
)
const toggleStatePersistence = (shouldPersist: boolean) => {
application.setValue(ShouldPersistNoteStateKey, shouldPersist)

View File

@@ -88,7 +88,7 @@ export class EditSmartViewModalController {
this.setIsSaving(true)
await this.application.mutator.changeAndSaveItem<SmartViewMutator>(this.view, (mutator) => {
await this.application.changeAndSaveItem<SmartViewMutator>(this.view, (mutator) => {
mutator.title = this.title
mutator.iconString = (this.icon as string) || SmartViewDefaultIconName
mutator.predicate = JSON.parse(this.predicateJson) as PredicateJsonForm
@@ -111,7 +111,10 @@ export class EditSmartViewModalController {
confirmButtonStyle: 'danger',
})
if (shouldDelete) {
this.application.mutator.deleteItem(view).catch(console.error)
this.application.mutator
.deleteItem(view)
.then(() => this.application.sync.sync())
.catch(console.error)
}
}

View File

@@ -47,10 +47,13 @@ const SmartViews = ({ application, featuresController }: Props) => {
confirmButtonStyle: 'danger',
})
if (shouldDelete) {
application.mutator.deleteItem(view).catch(console.error)
application.mutator
.deleteItem(view)
.then(() => application.sync.sync())
.catch(console.error)
}
},
[application.mutator],
[application.mutator, application.sync],
)
return (

View File

@@ -1,6 +1,6 @@
import { useApplication } from '@/Components/ApplicationProvider'
import Icon from '@/Components/Icon/Icon'
import { ContentType, ItemCounter } from '@standardnotes/snjs'
import { ContentType, StaticItemCounter } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import EncryptionStatusItem from './EncryptionStatusItem'
@@ -8,7 +8,7 @@ import { formatCount } from './formatCount'
const EncryptionEnabled: FunctionComponent = () => {
const application = useApplication()
const itemCounter = new ItemCounter()
const itemCounter = new StaticItemCounter()
const count = itemCounter.countNotesAndTags(application.items.getItems([ContentType.Note, ContentType.Tag]))
const files = application.items.getItems([ContentType.File])
const notes = formatCount(count.notes, 'notes')

View File

@@ -18,7 +18,7 @@ type Props = { viewControllerManager: ViewControllerManager }
const ErroredItems: FunctionComponent<Props> = ({ viewControllerManager }: Props) => {
const app = viewControllerManager.application
const [erroredItems, setErroredItems] = useState(app.items.invalidItems)
const [erroredItems, setErroredItems] = useState(app.items.invalidNonVaultedItems)
const getContentTypeDisplay = (item: EncryptedItemInterface): string => {
const display = DisplayStringForContentType(item.content_type)
@@ -44,7 +44,9 @@ const ErroredItems: FunctionComponent<Props> = ({ viewControllerManager }: Props
return
}
void app.mutator.deleteItems(items)
void app.mutator.deleteItems(items).then(() => {
void app.sync.sync()
})
setErroredItems(app.items.invalidItems)
}

View File

@@ -30,7 +30,7 @@ const Security: FunctionComponent<SecurityProps> = (props) => {
return (
<PreferencesPane>
<Encryption viewControllerManager={props.viewControllerManager} />
{props.application.items.invalidItems.length > 0 && (
{props.application.items.invalidNonVaultedItems.length > 0 && (
<ErroredItems viewControllerManager={props.viewControllerManager} />
)}
<Protections application={props.application} />

View File

@@ -1,5 +1,5 @@
import { WebApplication } from '@/Application/WebApplication'
export const securityPrefsHasBubble = (application: WebApplication): boolean => {
return application.items.invalidItems.length > 0
return application.items.invalidNonVaultedItems.length > 0
}

View File

@@ -0,0 +1,51 @@
import Button from '@/Components/Button/Button'
import Icon from '@/Components/Icon/Icon'
import ModalOverlay from '@/Components/Modal/ModalOverlay'
import { TrustedContactInterface } from '@standardnotes/snjs'
import EditContactModal from './EditContactModal'
import { useCallback, useState } from 'react'
import { useApplication } from '@/Components/ApplicationProvider'
type Props = {
contact: TrustedContactInterface
}
const ContactItem = ({ contact }: Props) => {
const application = useApplication()
const [isContactModalOpen, setIsContactModalOpen] = useState(false)
const closeContactModal = () => setIsContactModalOpen(false)
const collaborationID = application.contacts.getCollaborationIDForTrustedContact(contact)
const deleteContact = useCallback(async () => {
void application.contacts.deleteContact(contact)
}, [application.contacts, contact])
return (
<>
<ModalOverlay isOpen={isContactModalOpen} close={closeContactModal}>
<EditContactModal editContactUuid={contact.uuid} onCloseDialog={closeContactModal} />
</ModalOverlay>
<div className="bg-gray-100 flex flex-row gap-3.5 rounded-lg py-2.5 px-3.5 shadow-md">
<Icon type={'user'} size="custom" className="mt-2.5 h-5.5 w-5.5 flex-shrink-0" />
<div className="flex flex-col gap-2 py-1.5">
<span
className={`mr-auto overflow-hidden text-ellipsis text-base font-bold ${contact.isMe ? 'text-info' : ''}`}
>
{contact.name}
</span>
<span className="mr-auto overflow-hidden text-ellipsis text-sm">{collaborationID}</span>
<div className="mt-2.5 flex flex-row">
<Button label="Edit" className={'mr-3 text-xs'} onClick={() => setIsContactModalOpen(true)} />
<Button label="Delete" className={'mr-3 text-xs'} onClick={deleteContact} />
</div>
</div>
</div>
</>
)
}
export default ContactItem

View File

@@ -0,0 +1,127 @@
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
import Modal, { ModalAction } from '@/Components/Modal/Modal'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import { useApplication } from '@/Components/ApplicationProvider'
import { PendingSharedVaultInviteRecord, TrustedContactInterface } from '@standardnotes/snjs'
type Props = {
fromInvite?: PendingSharedVaultInviteRecord
editContactUuid?: string
onCloseDialog: () => void
onAddContact?: (contact: TrustedContactInterface) => void
}
const EditContactModal: FunctionComponent<Props> = ({ onCloseDialog, fromInvite, onAddContact, editContactUuid }) => {
const application = useApplication()
const [name, setName] = useState<string>('')
const [collaborationID, setCollaborationID] = useState<string>('')
const [editingContact, setEditingContact] = useState<TrustedContactInterface | undefined>(undefined)
const handleDialogClose = useCallback(() => {
onCloseDialog()
}, [onCloseDialog])
useEffect(() => {
if (fromInvite) {
setCollaborationID(application.contacts.getCollaborationIDFromInvite(fromInvite.invite))
}
}, [application.contacts, fromInvite])
useEffect(() => {
if (editContactUuid) {
const contact = application.contacts.findTrustedContact(editContactUuid)
if (!contact) {
throw new Error(`Contact with uuid ${editContactUuid} not found`)
}
setEditingContact(contact)
setName(contact.name)
setCollaborationID(application.contacts.getCollaborationIDForTrustedContact(contact))
}
}, [application.contacts, application.vaults, editContactUuid])
const handleSubmit = useCallback(async () => {
if (editingContact) {
void application.contacts.editTrustedContactFromCollaborationID(editingContact, { name, collaborationID })
handleDialogClose()
} else {
const contact = await application.contacts.addTrustedContactFromCollaborationID(collaborationID, name)
if (contact) {
onAddContact?.(contact)
handleDialogClose()
} else {
void application.alertService.alert('Unable to create contact. Please try again.')
}
}
}, [
editingContact,
application.contacts,
application.alertService,
name,
collaborationID,
handleDialogClose,
onAddContact,
])
const modalActions = useMemo(
(): ModalAction[] => [
{
label: editContactUuid ? 'Save Contact' : 'Add Contact',
onClick: handleSubmit,
type: 'primary',
mobileSlot: 'right',
},
{
label: 'Cancel',
onClick: handleDialogClose,
type: 'cancel',
mobileSlot: 'left',
},
],
[editContactUuid, handleDialogClose, handleSubmit],
)
return (
<Modal
title={editContactUuid ? 'Edit Contact' : 'Add New Contact'}
close={handleDialogClose}
actions={modalActions}
>
<div className="px-4.5 pt-4 pb-1.5">
<div className="flex w-full flex-col">
<div className="mb-3">
<DecoratedInput
className={{ container: 'mt-4' }}
id="invite-name-input"
value={name}
placeholder="Contact Name"
onChange={(value) => {
setName(value)
}}
/>
<DecoratedInput
className={{ container: 'mt-4' }}
id="invite-email-input"
value={collaborationID}
placeholder="Contact CollaborationID"
onChange={(value) => {
setCollaborationID(value)
}}
/>
{!editContactUuid && (
<p className="mt-4">
Ask your contact for their Standard Notes CollaborationID via secure email or chat. Then, enter it here
to add them as a contact.
</p>
)}
</div>
</div>
</div>
</Modal>
)
}
export default EditContactModal

View File

@@ -0,0 +1,97 @@
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
import Modal, { ModalAction } from '@/Components/Modal/Modal'
import { useApplication } from '@/Components/ApplicationProvider'
import { SharedVaultPermission, SharedVaultListingInterface, TrustedContactInterface } from '@standardnotes/snjs'
type Props = {
vault: SharedVaultListingInterface
onCloseDialog: () => void
}
const ContactInviteModal: FunctionComponent<Props> = ({ vault, onCloseDialog }) => {
const application = useApplication()
const [selectedContacts, setSelectedContacts] = useState<TrustedContactInterface[]>([])
const [contacts, setContacts] = useState<TrustedContactInterface[]>([])
useEffect(() => {
const loadContacts = async () => {
const contacts = await application.sharedVaults.getInvitableContactsForSharedVault(vault)
setContacts(contacts)
}
void loadContacts()
}, [application.sharedVaults, vault])
const handleDialogClose = useCallback(() => {
onCloseDialog()
}, [onCloseDialog])
const inviteSelectedContacts = useCallback(async () => {
for (const contact of selectedContacts) {
await application.sharedVaults.inviteContactToSharedVault(vault, contact, SharedVaultPermission.Write)
}
handleDialogClose()
}, [application.sharedVaults, vault, handleDialogClose, selectedContacts])
const toggleContact = useCallback(
(contact: TrustedContactInterface) => {
if (selectedContacts.includes(contact)) {
const index = selectedContacts.indexOf(contact)
const updatedContacts = [...selectedContacts]
updatedContacts.splice(index, 1)
setSelectedContacts(updatedContacts)
} else {
setSelectedContacts([...selectedContacts, contact])
}
},
[selectedContacts, setSelectedContacts],
)
const modalActions = useMemo(
(): ModalAction[] => [
{
label: 'Invite Selected Contacts',
onClick: inviteSelectedContacts,
type: 'primary',
mobileSlot: 'right',
},
{
label: 'Cancel',
onClick: handleDialogClose,
type: 'cancel',
mobileSlot: 'left',
},
],
[handleDialogClose, inviteSelectedContacts],
)
return (
<Modal title="Add New Contact" close={handleDialogClose} actions={modalActions}>
<div className="px-4.5 py-4">
<div className="flex w-full flex-col">
<div className="mb-3">
{contacts.map((contact) => {
return (
<div key={contact.uuid} onClick={() => toggleContact(contact)}>
<div>
<input
type="checkbox"
checked={selectedContacts.includes(contact)}
onChange={() => toggleContact(contact)}
/>
</div>
<div>
{contact.name}
{contact.contactUuid}
</div>
</div>
)
})}
</div>
</div>
</div>
</Modal>
)
}
export default ContactInviteModal

View File

@@ -0,0 +1,67 @@
import { useApplication } from '@/Components/ApplicationProvider'
import Button from '@/Components/Button/Button'
import Icon from '@/Components/Icon/Icon'
import ModalOverlay from '@/Components/Modal/ModalOverlay'
import { PendingSharedVaultInviteRecord } from '@standardnotes/snjs'
import { useCallback, useState } from 'react'
import EditContactModal from '../Contacts/EditContactModal'
type Props = {
invite: PendingSharedVaultInviteRecord
}
const InviteItem = ({ invite }: Props) => {
const application = useApplication()
const [isAddContactModalOpen, setIsAddContactModalOpen] = useState(false)
const isTrusted = invite.trusted
const inviteData = invite.message.data
const addAsTrustedContact = useCallback(() => {
setIsAddContactModalOpen(true)
}, [])
const acceptInvite = useCallback(async () => {
await application.sharedVaults.acceptPendingSharedVaultInvite(invite)
}, [application.sharedVaults, invite])
const closeAddContactModal = () => setIsAddContactModalOpen(false)
const collaborationId = application.contacts.getCollaborationIDFromInvite(invite.invite)
return (
<>
<ModalOverlay isOpen={isAddContactModalOpen} close={closeAddContactModal}>
<EditContactModal fromInvite={invite} onCloseDialog={closeAddContactModal} />
</ModalOverlay>
<div className="bg-gray-100 flex flex-row gap-3.5 rounded-lg py-2.5 px-3.5 shadow-md">
<Icon type={'archive'} size="custom" className="mt-2.5 h-5.5 w-5.5 flex-shrink-0" />
<div className="flex flex-col gap-2 py-1.5">
<span className="mr-auto overflow-hidden text-ellipsis text-sm">Vault Name: {inviteData.metadata.name}</span>
<span className="mr-auto overflow-hidden text-ellipsis text-sm">
Vault Description: {inviteData.metadata.description}
</span>
<span className="mr-auto overflow-hidden text-ellipsis text-sm">
Sender CollaborationID: {collaborationId}
</span>
<div className="mt-2.5 flex flex-row">
{isTrusted ? (
<Button label="Accept Invite" className={'mr-3 text-xs'} onClick={acceptInvite} />
) : (
<div>
<div>
The sender of this invite is not trusted. To accept this invite, first add the sender as a trusted
contact.
</div>
<Button label="Add Trusted Contact" className={'mr-3 text-xs'} onClick={addAsTrustedContact} />
</div>
)}
</div>
</div>
</div>
</>
)
}
export default InviteItem

View File

@@ -0,0 +1,157 @@
import { observer } from 'mobx-react-lite'
import { Subtitle, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
import { useApplication } from '@/Components/ApplicationProvider'
import ContactItem from './Contacts/ContactItem'
import ModalOverlay from '@/Components/Modal/ModalOverlay'
import EditContactModal from './Contacts/EditContactModal'
import { useCallback, useEffect, useState } from 'react'
import {
VaultListingInterface,
TrustedContactInterface,
PendingSharedVaultInviteRecord,
ContentType,
SharedVaultServiceEvent,
} from '@standardnotes/snjs'
import VaultItem from './Vaults/VaultItem'
import Button from '@/Components/Button/Button'
import InviteItem from './Invites/InviteItem'
import EditVaultModal from './Vaults/VaultModal/EditVaultModal'
const Vaults = () => {
const application = useApplication()
const [vaults, setVaults] = useState<VaultListingInterface[]>([])
const [invites, setInvites] = useState<PendingSharedVaultInviteRecord[]>([])
const [contacts, setContacts] = useState<TrustedContactInterface[]>([])
const [isAddContactModalOpen, setIsAddContactModalOpen] = useState(false)
const closeAddContactModal = () => setIsAddContactModalOpen(false)
const [isVaultModalOpen, setIsVaultModalOpen] = useState(false)
const closeVaultModal = () => setIsVaultModalOpen(false)
const vaultService = application.vaults
const sharedVaultService = application.sharedVaults
const contactService = application.contacts
const updateVaults = useCallback(async () => {
setVaults(vaultService.getVaults())
}, [vaultService])
const fetchInvites = useCallback(async () => {
await sharedVaultService.downloadInboundInvites()
const invites = sharedVaultService.getCachedPendingInviteRecords()
setInvites(invites)
}, [sharedVaultService])
const updateContacts = useCallback(async () => {
setContacts(contactService.getAllContacts())
}, [contactService])
useEffect(() => {
return application.sharedVaults.addEventObserver((event) => {
if (event === SharedVaultServiceEvent.SharedVaultStatusChanged) {
void fetchInvites()
}
})
})
useEffect(() => {
return application.streamItems([ContentType.VaultListing, ContentType.TrustedContact], () => {
void updateVaults()
void fetchInvites()
void updateContacts()
})
}, [application, updateVaults, fetchInvites, updateContacts])
const createNewVault = useCallback(async () => {
setIsVaultModalOpen(true)
}, [])
const createNewContact = useCallback(() => {
setIsAddContactModalOpen(true)
}, [])
useEffect(() => {
void updateVaults()
void fetchInvites()
void updateContacts()
}, [updateContacts, updateVaults, fetchInvites])
return (
<>
<ModalOverlay isOpen={isAddContactModalOpen} close={closeAddContactModal}>
<EditContactModal onCloseDialog={closeAddContactModal} />
</ModalOverlay>
<ModalOverlay isOpen={isVaultModalOpen} close={closeVaultModal}>
<EditVaultModal onCloseDialog={closeVaultModal} />
</ModalOverlay>
<PreferencesGroup>
<PreferencesSegment>
<Title>Incoming Invites</Title>
<div className="my-2 flex flex-col">
{invites.map((invite) => {
return <InviteItem invite={invite} key={invite.invite.uuid} />
})}
</div>
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Contacts</Title>
<div className="my-2 flex flex-col">
{contacts.map((contact) => {
return <ContactItem contact={contact} key={contact.uuid} />
})}
</div>
<div className="mt-2.5 flex flex-row">
<Button label="Add New Contact" className={'mr-3 text-xs'} onClick={createNewContact} />
</div>
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>Vaults</Title>
<div className="my-2 flex flex-col">
{vaults.map((vault) => {
return <VaultItem vault={vault} key={vault.uuid} />
})}
</div>
<div className="mt-2.5 flex flex-row">
<Button label="Create New Vault" className={'mr-3 text-xs'} onClick={createNewVault} />
</div>
</PreferencesSegment>
</PreferencesGroup>
<PreferencesGroup>
<PreferencesSegment>
<Title>CollaborationID</Title>
<Subtitle>Share your CollaborationID with collaborators to join their vaults.</Subtitle>
{contactService.isCollaborationEnabled() ? (
<div className="mt-2.5 flex flex-row">
<code>
<pre>{contactService.getCollaborationID()}</pre>
</code>
</div>
) : (
<div className="mt-2.5 flex flex-row">
<Button
label="Enable Vault Sharing"
className={'mr-3 text-xs'}
onClick={() => contactService.enableCollaboration()}
/>
</div>
)}
</PreferencesSegment>
</PreferencesGroup>
</>
)
}
export default observer(Vaults)

View File

@@ -0,0 +1,146 @@
import { useApplication } from '@/Components/ApplicationProvider'
import Button from '@/Components/Button/Button'
import Icon from '@/Components/Icon/Icon'
import ModalOverlay from '@/Components/Modal/ModalOverlay'
import { ButtonType, VaultListingInterface, isClientDisplayableError } from '@standardnotes/snjs'
import { useCallback, useState } from 'react'
import ContactInviteModal from '../Invites/ContactInviteModal'
import EditVaultModal from './VaultModal/EditVaultModal'
type Props = {
vault: VaultListingInterface
}
const VaultItem = ({ vault }: Props) => {
const application = useApplication()
const [isInviteModalOpen, setIsAddContactModalOpen] = useState(false)
const closeInviteModal = () => setIsAddContactModalOpen(false)
const [isVaultModalOpen, setIsVaultModalOpen] = useState(false)
const closeVaultModal = () => setIsVaultModalOpen(false)
const isAdmin = !vault.isSharedVaultListing() ? true : application.sharedVaults.isCurrentUserSharedVaultAdmin(vault)
const deleteVault = useCallback(async () => {
const confirm = await application.alerts.confirm(
'Deleting a vault will permanently delete all its items and files.',
'Are you sure you want to delete this vault?',
undefined,
ButtonType.Danger,
)
if (!confirm) {
return
}
if (vault.isSharedVaultListing()) {
const result = await application.sharedVaults.deleteSharedVault(vault)
if (isClientDisplayableError(result)) {
void application.alerts.showErrorAlert(result)
}
} else {
const success = await application.vaults.deleteVault(vault)
if (!success) {
void application.alerts.alert('Unable to delete vault. Please try again.')
}
}
}, [application.alerts, application.sharedVaults, application.vaults, vault])
const leaveVault = useCallback(async () => {
if (!vault.isSharedVaultListing()) {
return
}
const confirm = await application.alerts.confirm(
'All items and files in this vault will be removed from your account.',
'Are you sure you want to leave this vault?',
undefined,
ButtonType.Danger,
)
if (!confirm) {
return
}
const success = await application.sharedVaults.leaveSharedVault(vault)
if (!success) {
void application.alerts.alert('Unable to leave vault. Please try again.')
}
}, [application.alerts, application.sharedVaults, vault])
const convertToSharedVault = useCallback(async () => {
await application.sharedVaults.convertVaultToSharedVault(vault)
}, [application.sharedVaults, vault])
const ensureVaultIsUnlocked = useCallback(async () => {
if (!application.vaults.isVaultLocked(vault)) {
return true
}
const unlocked = await application.vaultDisplayService.unlockVault(vault)
return unlocked
}, [application.vaultDisplayService, application.vaults, vault])
const openEditModal = useCallback(async () => {
if (!(await ensureVaultIsUnlocked())) {
return
}
setIsVaultModalOpen(true)
}, [ensureVaultIsUnlocked])
const openInviteModal = useCallback(async () => {
if (!(await ensureVaultIsUnlocked())) {
return
}
setIsAddContactModalOpen(true)
}, [ensureVaultIsUnlocked])
return (
<>
{vault.isSharedVaultListing() && (
<ModalOverlay isOpen={isInviteModalOpen} close={closeInviteModal}>
<ContactInviteModal vault={vault} onCloseDialog={closeInviteModal} />
</ModalOverlay>
)}
<ModalOverlay isOpen={isVaultModalOpen} close={closeVaultModal}>
<EditVaultModal existingVaultUuid={vault.uuid} onCloseDialog={closeVaultModal} />
</ModalOverlay>
<div className="bg-gray-100 flex flex-row gap-3.5 rounded-lg py-2.5 px-3.5 shadow-md">
<Icon type={'safe-square'} size="custom" className="mt-2.5 h-5.5 w-5.5 flex-shrink-0" />
<div className="flex flex-col gap-2 py-1.5">
<span className="mr-auto overflow-hidden text-ellipsis text-base font-bold">{vault.name}</span>
<span className="mr-auto overflow-hidden text-ellipsis text-sm">{vault.description}</span>
<span className="mr-auto overflow-hidden text-ellipsis text-sm">Vault ID: {vault.systemIdentifier}</span>
<div className="mt-2.5 flex w-full flex-row justify-between">
<div className="mt-2.5 flex flex-row">
<Button label="Edit" className={'mr-3 text-xs'} onClick={openEditModal} />
{isAdmin && (
<Button colorStyle="danger" label="Delete" className={'mr-3 text-xs'} onClick={deleteVault} />
)}
{!isAdmin && vault.isSharedVaultListing() && (
<Button label="Leave Vault" className={'mr-3 text-xs'} onClick={leaveVault} />
)}
</div>
<div className="mt-2.5 flex flex-row">
{vault.isSharedVaultListing() ? (
<Button label="Invite Contacts" className={'mr-3 text-xs'} onClick={openInviteModal} />
) : (
<Button
colorStyle="info"
label="Enable Collaboration"
className={'mr-3 text-xs'}
onClick={convertToSharedVault}
/>
)}
</div>
</div>
</div>
</div>
</>
)
}
export default VaultItem

View File

@@ -0,0 +1,224 @@
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
import Modal, { ModalAction } from '@/Components/Modal/Modal'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import { useApplication } from '@/Components/ApplicationProvider'
import {
ChangeVaultOptionsDTO,
KeySystemRootKeyPasswordType,
KeySystemRootKeyStorageMode,
SharedVaultInviteServerHash,
SharedVaultUserServerHash,
VaultListingInterface,
isClientDisplayableError,
} from '@standardnotes/snjs'
import { VaultModalMembers } from './VaultModalMembers'
import { VaultModalInvites } from './VaultModalInvites'
import { PasswordTypePreference } from './PasswordTypePreference'
import { KeyStoragePreference } from './KeyStoragePreference'
import useItem from '@/Hooks/useItem'
type Props = {
existingVaultUuid?: string
onCloseDialog: () => void
}
const EditVaultModal: FunctionComponent<Props> = ({ onCloseDialog, existingVaultUuid }) => {
const application = useApplication()
const existingVault = useItem<VaultListingInterface>(existingVaultUuid)
const [name, setName] = useState<string>('')
const [description, setDescription] = useState<string>('')
const [members, setMembers] = useState<SharedVaultUserServerHash[]>([])
const [invites, setInvites] = useState<SharedVaultInviteServerHash[]>([])
const [isAdmin, setIsAdmin] = useState<boolean>(true)
const [passwordType, setPasswordType] = useState<KeySystemRootKeyPasswordType>(
KeySystemRootKeyPasswordType.Randomized,
)
const [keyStorageMode, setKeyStorageMode] = useState<KeySystemRootKeyStorageMode>(KeySystemRootKeyStorageMode.Synced)
const [customPassword, setCustomPassword] = useState<string | undefined>(undefined)
useEffect(() => {
if (existingVault) {
setName(existingVault.name ?? '')
setDescription(existingVault.description ?? '')
setPasswordType(existingVault.rootKeyParams.passwordType)
setKeyStorageMode(existingVault.keyStorageMode)
}
}, [application.vaults, existingVault])
const reloadVaultInfo = useCallback(async () => {
if (!existingVault) {
return
}
if (existingVault.isSharedVaultListing()) {
setIsAdmin(
existingVault.isSharedVaultListing() && application.sharedVaults.isCurrentUserSharedVaultAdmin(existingVault),
)
const users = await application.sharedVaults.getSharedVaultUsers(existingVault)
if (users) {
setMembers(users)
}
const invites = await application.sharedVaults.getOutboundInvites(existingVault)
if (!isClientDisplayableError(invites)) {
setInvites(invites)
}
}
}, [application.sharedVaults, existingVault])
useEffect(() => {
void reloadVaultInfo()
}, [application.vaults, reloadVaultInfo])
const handleDialogClose = useCallback(() => {
onCloseDialog()
}, [onCloseDialog])
const saveExistingVault = useCallback(
async (vault: VaultListingInterface) => {
if (vault.name !== name || vault.description !== description) {
await application.vaults.changeVaultNameAndDescription(vault, {
name: name,
description: description,
})
}
const isChangingPasswordType = vault.keyPasswordType !== passwordType
const isChangingKeyStorageMode = vault.keyStorageMode !== keyStorageMode
const getPasswordTypeParams = (): ChangeVaultOptionsDTO['newPasswordType'] => {
if (!isChangingPasswordType) {
throw new Error('Password type is not changing')
}
if (passwordType === KeySystemRootKeyPasswordType.UserInputted) {
if (!customPassword) {
throw new Error('Custom password is not set')
}
return {
passwordType,
userInputtedPassword: customPassword,
}
} else {
return {
passwordType,
}
}
}
if (isChangingPasswordType || isChangingKeyStorageMode) {
await application.vaults.changeVaultOptions({
vault,
newPasswordType: isChangingPasswordType ? getPasswordTypeParams() : undefined,
newKeyStorageMode: isChangingKeyStorageMode ? keyStorageMode : undefined,
})
}
},
[application.vaults, customPassword, description, keyStorageMode, name, passwordType],
)
const createNewVault = useCallback(async () => {
if (passwordType === KeySystemRootKeyPasswordType.UserInputted) {
if (!customPassword) {
throw new Error('Custom key is not set')
}
await application.vaults.createUserInputtedPasswordVault({
name,
description,
storagePreference: keyStorageMode,
userInputtedPassword: customPassword,
})
} else {
await application.vaults.createRandomizedVault({
name,
description,
storagePreference: keyStorageMode,
})
}
handleDialogClose()
}, [application.vaults, customPassword, description, handleDialogClose, keyStorageMode, name, passwordType])
const handleSubmit = useCallback(async () => {
if (existingVault) {
await saveExistingVault(existingVault)
} else {
await createNewVault()
}
handleDialogClose()
}, [existingVault, handleDialogClose, saveExistingVault, createNewVault])
const modalActions = useMemo(
(): ModalAction[] => [
{
label: existingVault ? 'Save Vault' : 'Create Vault',
onClick: handleSubmit,
type: 'primary',
mobileSlot: 'right',
},
{
label: 'Cancel',
onClick: handleDialogClose,
type: 'cancel',
mobileSlot: 'left',
},
],
[existingVault, handleDialogClose, handleSubmit],
)
if (existingVault && application.vaults.isVaultLocked(existingVault)) {
return <div>Vault is locked.</div>
}
return (
<Modal title={existingVault ? 'Edit Vault' : 'Create New Vault'} close={handleDialogClose} actions={modalActions}>
<div className="px-4.5 pt-4 pb-1.5">
<div className="flex w-full flex-col">
<div className="mb-3">
<div className="text-lg">Vault Info</div>
<div className="mt-1">The vault name and description are end-to-end encrypted.</div>
<DecoratedInput
className={{ container: 'mt-4' }}
id="vault-name-input"
value={name}
placeholder="Vault Name"
onChange={(value) => {
setName(value)
}}
/>
<DecoratedInput
className={{ container: 'mt-4' }}
id="vault-email-input"
value={description}
placeholder="Vault description"
onChange={(value) => {
setDescription(value)
}}
/>
</div>
{existingVault && (
<VaultModalMembers vault={existingVault} members={members} onChange={reloadVaultInfo} isAdmin={isAdmin} />
)}
{existingVault && <VaultModalInvites invites={invites} onChange={reloadVaultInfo} isAdmin={isAdmin} />}
<PasswordTypePreference
value={passwordType}
onChange={setPasswordType}
onCustomKeyChange={setCustomPassword}
/>
<KeyStoragePreference value={keyStorageMode} onChange={setKeyStorageMode} />
</div>
</div>
</Modal>
)
}
export default EditVaultModal

View File

@@ -0,0 +1,57 @@
import { KeySystemRootKeyStorageMode } from '@standardnotes/snjs'
import StyledRadioInput from '@/Components/Radio/StyledRadioInput'
type KeyStorageOption = {
value: KeySystemRootKeyStorageMode
label: string
description: string
}
const options: KeyStorageOption[] = [
{
value: KeySystemRootKeyStorageMode.Synced,
label: 'Synced (Recommended)',
description:
'Your vault key will be encrypted and synced to your account and automatically available on your other devices.',
},
{
value: KeySystemRootKeyStorageMode.Local,
label: 'Local',
description:
'Your vault key will be encrypted and saved locally on this device. You will need to manually enter your vault key on your other devices.',
},
{
value: KeySystemRootKeyStorageMode.Ephemeral,
label: 'Ephemeral',
description:
'Your vault key will only be stored in memory and will be forgotten when you close the app. You will need to manually enter your vault key on your other devices.',
},
]
export const KeyStoragePreference = ({
value,
onChange,
}: {
value: KeySystemRootKeyStorageMode
onChange: (value: KeySystemRootKeyStorageMode) => void
}) => {
return (
<div className="mb-3">
<div className="mb-3 text-lg">Vault Key Type</div>
{options.map((option) => {
return (
<label key={option.value} className="mb-2 flex items-center gap-2 text-base font-medium md:text-sm">
<StyledRadioInput
name="option"
checked={value === option.value}
onChange={() => {
onChange(option.value)
}}
/>
{option.label}
</label>
)
})}
</div>
)
}

View File

@@ -0,0 +1,75 @@
import { KeySystemRootKeyPasswordType } from '@standardnotes/snjs'
import StyledRadioInput from '@/Components/Radio/StyledRadioInput'
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import { useState } from 'react'
type PasswordTypePreference = {
value: KeySystemRootKeyPasswordType
label: string
description: string
}
const options: PasswordTypePreference[] = [
{
value: KeySystemRootKeyPasswordType.Randomized,
label: 'Randomized (Recommended)',
description: 'Your vault key will be randomly generated and synced to your account.',
},
{
value: KeySystemRootKeyPasswordType.UserInputted,
label: 'Custom (Advanced)',
description:
'Choose your own key for your vault. This is an advanced option and is not recommended for most users.',
},
]
export const PasswordTypePreference = ({
value,
onChange,
onCustomKeyChange,
}: {
value: KeySystemRootKeyPasswordType
onChange: (value: KeySystemRootKeyPasswordType) => void
onCustomKeyChange: (value: string) => void
}) => {
const [customKey, setCustomKey] = useState('')
const onKeyInputChange = (value: string) => {
setCustomKey(value)
onCustomKeyChange(value)
}
return (
<div className="mb-3">
<div className="mb-3 text-lg">Vault Key Type</div>
{options.map((option) => {
return (
<label key={option.value} className="mb-2 flex items-center gap-2 text-base font-medium md:text-sm">
<StyledRadioInput
name="option"
checked={value === option.value}
onChange={() => {
onChange(option.value)
}}
/>
{option.label}
</label>
)
})}
{value === KeySystemRootKeyPasswordType.UserInputted && (
<div>
<div className="text-gray-500 mt-3 text-sm">{options[1].description}</div>
<DecoratedPasswordInput
placeholder="Choose a password"
id="key-input"
value={customKey}
onChange={onKeyInputChange}
type="password"
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,57 @@
import { useCallback } from 'react'
import { useApplication } from '@/Components/ApplicationProvider'
import { SharedVaultInviteServerHash } from '@standardnotes/snjs'
import Icon from '@/Components/Icon/Icon'
import Button from '@/Components/Button/Button'
export const VaultModalInvites = ({
invites,
onChange,
isAdmin,
}: {
invites: SharedVaultInviteServerHash[]
onChange: () => void
isAdmin: boolean
}) => {
const application = useApplication()
const deleteInvite = useCallback(
async (invite: SharedVaultInviteServerHash) => {
await application.sharedVaults.deleteInvite(invite)
onChange()
},
[application.sharedVaults, onChange],
)
return (
<div className="mb-3">
<div className="mb-3 text-lg">Pending Invites</div>
{invites.map((invite) => {
const contact = application.contacts.findTrustedContactForInvite(invite)
return (
<div className="bg-gray-100 flex flex-row gap-3.5 rounded-lg py-2.5 px-3.5 shadow-md">
<Icon type={'user'} size="custom" className="mt-2.5 h-5.5 w-5.5 flex-shrink-0" />
<div className="flex flex-col gap-2 py-1.5">
<span className="mr-auto overflow-hidden text-ellipsis text-base font-bold">
{contact?.name || invite.user_uuid}
</span>
{contact && <span className="text-info">Trusted</span>}
{!contact && (
<div>
<span className="text-base">Untrusted</span>
</div>
)}
{isAdmin && (
<div className="mt-2.5 flex flex-row">
<Button label="Cancel Invite" className={'mr-3 text-xs'} onClick={() => deleteInvite(invite)} />
</div>
)}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,71 @@
import { useCallback } from 'react'
import { useApplication } from '@/Components/ApplicationProvider'
import { SharedVaultUserServerHash, VaultListingInterface } from '@standardnotes/snjs'
import Icon from '@/Components/Icon/Icon'
import Button from '@/Components/Button/Button'
export const VaultModalMembers = ({
members,
isAdmin,
vault,
onChange,
}: {
members: SharedVaultUserServerHash[]
vault: VaultListingInterface
isAdmin: boolean
onChange: () => void
}) => {
const application = useApplication()
const removeMemberFromVault = useCallback(
async (memberItem: SharedVaultUserServerHash) => {
if (vault.isSharedVaultListing()) {
await application.sharedVaults.removeUserFromSharedVault(vault, memberItem.user_uuid)
onChange()
}
},
[application.sharedVaults, vault, onChange],
)
return (
<div className="mb-3">
<div className="mb-3 text-lg">Vault Members</div>
{members.map((member) => {
if (application.sharedVaults.isSharedVaultUserSharedVaultOwner(member)) {
return null
}
const contact = application.contacts.findTrustedContactForServerUser(member)
return (
<div
key={contact?.uuid || member.user_uuid}
className="bg-gray-100 flex flex-row gap-3.5 rounded-lg py-2.5 px-3.5 shadow-md"
>
<Icon type={'user'} size="custom" className="mt-2.5 h-5.5 w-5.5 flex-shrink-0" />
<div className="flex flex-col gap-2 py-1.5">
<span className="mr-auto overflow-hidden text-ellipsis text-base font-bold">
{contact?.name || member.user_uuid}
</span>
{contact && <span className="text-info">Trusted</span>}
{!contact && (
<div>
<span className="text-base">Untrusted</span>
</div>
)}
{isAdmin && (
<div className="mt-2.5 flex flex-row">
<Button
label="Remove From Vault"
className={'mr-3 text-xs'}
onClick={() => removeMemberFromVault(member)}
/>
</div>
)}
</div>
</div>
)
})}
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { WebApplication } from '@/Application/WebApplication'
import { PackageProvider } from './Panes/General/Advanced/Packages/Provider/PackageProvider'
import { securityPrefsHasBubble } from './Panes/Security/securityPrefsHasBubble'
import { PreferenceId } from '@standardnotes/ui-services'
import { featureTrunkVaultsEnabled } from '@/FeatureTrunk'
interface PreferencesMenuItem {
readonly id: PreferenceId
@@ -44,6 +45,11 @@ const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' },
]
if (featureTrunkVaultsEnabled()) {
PREFERENCES_MENU_ITEMS.splice(3, 0, { id: 'vaults', label: 'Vaults', icon: 'safe-square' })
READY_PREFERENCES_MENU_ITEMS.splice(3, 0, { id: 'vaults', label: 'Vaults', icon: 'safe-square' })
}
export class PreferencesMenu {
private _selectedPane: PreferenceId = 'account'
private _menu: PreferencesMenuItem[]

View File

@@ -102,9 +102,9 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = ({ application, quickSet
const toggleComponent = useCallback(
(component: SNComponent) => {
if (component.isTheme()) {
application.mutator.toggleTheme(component).catch(console.error)
application.componentManager.toggleTheme(component.uuid).catch(console.error)
} else {
application.mutator.toggleComponent(component).catch(console.error)
application.componentManager.toggleComponent(component.uuid).catch(console.error)
}
},
[application],
@@ -113,7 +113,7 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = ({ application, quickSet
const deactivateAnyNonLayerableTheme = useCallback(() => {
const activeTheme = themes.map((item) => item.component).find((theme) => theme?.active && !theme.isLayerable())
if (activeTheme) {
application.mutator.toggleTheme(activeTheme).catch(console.error)
application.componentManager.toggleTheme(activeTheme.uuid).catch(console.error)
}
}, [application, themes])

View File

@@ -38,7 +38,7 @@ const ThemesMenuButton: FunctionComponent<Props> = ({ application, item }) => {
const themeIsLayerableOrNotActive = isThemeLayerable || !item.component.active
if (themeIsLayerableOrNotActive) {
application.mutator.toggleTheme(item.component).catch(console.error)
application.componentManager.toggleTheme(item.component.uuid).catch(console.error)
}
} else {
premiumModal.activate(`${item.name} theme`)

View File

@@ -5,9 +5,10 @@ type Props = {
items: { label: string; value: string }[]
value: string
onChange: (value: string) => void
className?: string
}
const RadioButtonGroup = ({ value, items, onChange }: Props) => {
const RadioButtonGroup = ({ value, items, onChange, className }: Props) => {
const radio = useRadioStore({
value,
orientation: 'horizontal',
@@ -17,7 +18,7 @@ const RadioButtonGroup = ({ value, items, onChange }: Props) => {
})
return (
<RadioGroup store={radio} className="flex divide-x divide-border rounded border border-border">
<RadioGroup store={radio} className={`flex divide-x divide-border rounded border border-border ${className ?? ''}`}>
{items.map(({ label, value: itemValue }) => (
<label
className={classNames(

View File

@@ -89,7 +89,13 @@ export class AddSmartViewModalController {
? JSON.parse(this.customPredicateJson)
: this.predicateController.toJson()
const predicate = predicateFromJson(predicateJson as PredicateJsonForm)
await this.application.items.createSmartView(this.title, predicate, this.icon as string)
await this.application.mutator.createSmartView({
title: this.title,
predicate,
iconString: this.icon as string,
vault: this.application.vaultDisplayService.exclusivelyShownVault,
})
this.setIsSaving(false)
this.closeModal()

View File

@@ -37,7 +37,7 @@ export default function FilePlugin({ currentNote }: { currentNote: SNNote }): JS
if (uploadedFile) {
editor.dispatchCommand(INSERT_FILE_COMMAND, uploadedFile.uuid)
void linkingController.linkItemToSelectedItem(uploadedFile)
void application.mutator.changeAndSaveItem(uploadedFile, (mutator) => {
void application.changeAndSaveItem(uploadedFile, (mutator) => {
mutator.protected = currentNote.protected
})
}
@@ -76,7 +76,7 @@ export default function FilePlugin({ currentNote }: { currentNote: SNNote }): JS
COMMAND_PRIORITY_NORMAL,
),
)
}, [application.mutator, currentNote.protected, editor, filesController, linkingController])
}, [application, currentNote.protected, editor, filesController, linkingController])
useEffect(() => {
const disposer = filesController.addEventObserver((event, data) => {

View File

@@ -55,7 +55,7 @@ const SuperNoteConverter = ({
return undefined
}
const templateNoteForRevision = application.mutator.createTemplateItem<NoteContent, SNNote>(ContentType.Note, {
const templateNoteForRevision = application.items.createTemplateItem<NoteContent, SNNote>(ContentType.Note, {
title: note.title,
text: convertedContent,
references: note.references,
@@ -66,7 +66,7 @@ const SuperNoteConverter = ({
componentViewer.lockReadonly = true
componentViewer.overrideContextItem = templateNoteForRevision
return componentViewer
}, [application.componentManager, application.mutator, component, convertedContent, note.references, note.title])
}, [application.componentManager, application.items, component, convertedContent, note.references, note.title])
useEffect(() => {
return () => {

View File

@@ -14,6 +14,7 @@ import { PaneLayout } from '@/Controllers/PaneController/PaneLayout'
import { usePaneSwipeGesture } from '../Panes/usePaneGesture'
import { mergeRefs } from '@/Hooks/mergeRefs'
import { useAvailableSafeAreaPadding } from '@/Hooks/useSafeAreaPadding'
import { featureTrunkVaultsEnabled } from '@/FeatureTrunk'
type Props = {
application: WebApplication
@@ -136,6 +137,16 @@ const Navigation = forwardRef<HTMLDivElement, Props>(({ application, className,
label="Go to quick settings menu"
icon="themes"
/>
{featureTrunkVaultsEnabled() && (
<RoundIconButton
className="ml-2.5 bg-default"
onClick={() => {
viewControllerManager.vaultSelectionController.toggle()
}}
label="Go to vaults menu"
icon="safe-square"
/>
)}
</div>
{children}
</div>

View File

@@ -12,6 +12,8 @@ import { formatDateForContextMenu } from '@/Utils/DateUtils'
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
import Popover from '../Popover/Popover'
import IconPicker from '../Icon/IconPicker'
import AddToVaultMenuOption from '../Vaults/AddToVaultMenuOption'
import { featureTrunkVaultsEnabled } from '@/FeatureTrunk'
type ContextMenuProps = {
navigationController: NavigationController
@@ -25,6 +27,8 @@ const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag
const { contextMenuOpen, contextMenuClickLocation, application } = navigationController
const contextMenuRef = useRef<HTMLDivElement>(null)
/** @TODO Needs fixing to handle clicking on the vault selection sub menu */
useCloseOnClickOutside(contextMenuRef, () => navigationController.setContextMenuOpen(false))
const onClickAddSubtag = useCallback(() => {
@@ -82,6 +86,9 @@ const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag
iconGridClassName="max-h-30"
/>
<HorizontalSeparator classes="my-2" />
{featureTrunkVaultsEnabled() && (
<AddToVaultMenuOption iconClassName="mr-2 text-neutral" items={[selectedTag]} />
)}
<MenuItem className={'justify-between py-1.5'} onClick={onClickStar}>
<div className="flex items-center">
<Icon type="star" className="mr-2 text-neutral" />

View File

@@ -45,6 +45,7 @@ const TagsSection: FunctionComponent<Props> = ({ viewControllerManager }) => {
viewControllerManager.application.mutator
.migrateTagsToFolders()
.then(() => {
void viewControllerManager.application.sync.sync()
checkIfMigrationNeeded()
})
.catch(console.error)

View File

@@ -0,0 +1,52 @@
import { FunctionComponent, useCallback } from 'react'
import Menu from '../Menu/Menu'
import { useApplication } from '../ApplicationProvider'
import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
import Icon from '../Icon/Icon'
import { VaultListingInterface } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
const ManyVaultSelectionMenu: FunctionComponent = () => {
const application = useApplication()
const vaults = application.vaults.getVaults()
const isVaultVisible = useCallback(
(vault: VaultListingInterface) => {
return !application.vaultDisplayService.isVaultDisabledOrLocked(vault)
},
[application],
)
const toggleVault = useCallback(
(vault: VaultListingInterface) => {
if (isVaultVisible(vault)) {
application.vaultDisplayService.hideVault(vault)
} else {
application.vaultDisplayService.unhideVault(vault)
}
},
[isVaultVisible, application],
)
return (
<Menu a11yLabel="Vault selection menu" isOpen>
{vaults.map((vault) => (
<MenuSwitchButtonItem
onChange={() => {
toggleVault(vault)
}}
checked={isVaultVisible(vault)}
key={vault.uuid}
>
<Icon type="safe-square" className="mr-2 text-neutral" />
<div className="flex w-full items-center gap-1">
{vault.name}
{application.vaults.isVaultLocked(vault) && <Icon className="ml-1" type="lock" size={'small'} />}
</div>
</MenuSwitchButtonItem>
))}
</Menu>
)
}
export default observer(ManyVaultSelectionMenu)

View File

@@ -0,0 +1,41 @@
import { FunctionComponent, useCallback } from 'react'
import Menu from '../Menu/Menu'
import { useApplication } from '../ApplicationProvider'
import { VaultListingInterface } from '@standardnotes/snjs'
import MenuRadioButtonItem from '../Menu/MenuRadioButtonItem'
import { observer } from 'mobx-react-lite'
import Icon from '../Icon/Icon'
const SingleVaultSelectionMenu: FunctionComponent = () => {
const application = useApplication()
const vaults = application.vaults.getVaults()
const isVaultVisible = useCallback(
(vault: VaultListingInterface) => {
return application.vaultDisplayService.isVaultExclusivelyShown(vault)
},
[application],
)
const selectVault = useCallback(
(vault: VaultListingInterface) => {
application.vaultDisplayService.showOnlyVault(vault)
},
[application],
)
return (
<Menu a11yLabel="Vault selection menu" isOpen>
{vaults.map((vault) => (
<MenuRadioButtonItem key={vault.uuid} checked={isVaultVisible(vault)} onClick={() => selectVault(vault)}>
<div className="flex w-full items-center gap-1">
{vault.name}
{application.vaults.isVaultLocked(vault) && <Icon className="ml-1" type="lock" size={'small'} />}
</div>
</MenuRadioButtonItem>
))}
</Menu>
)
}
export default observer(SingleVaultSelectionMenu)

View File

@@ -0,0 +1,51 @@
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useState } from 'react'
import Menu from '../Menu/Menu'
import { VaultSelectionMenuController } from '@/Controllers/VaultSelectionMenuController'
import RadioButtonGroup from '@/Components/RadioButtonGroup/RadioButtonGroup'
import ManyVaultSelectionMenu from './ManyVaultSelectionMenu'
import SingleVaultSelectionMenu from './SingleVaultSelectionMenu'
import { useApplication } from '../ApplicationProvider'
type MenuProps = {
controller: VaultSelectionMenuController
}
type SettingsMode = 'many' | 'single'
const VaultSelectionMenu: FunctionComponent<MenuProps> = () => {
const application = useApplication()
const [mode, setMode] = useState<SettingsMode>(
application.vaultDisplayService.isInExclusiveDisplayMode() ? 'single' : 'many',
)
const changeSelectionMode = (mode: SettingsMode) => {
setMode(mode)
if (mode === 'many') {
if (application.vaultDisplayService.exclusivelyShownVault) {
application.vaultDisplayService.changeToMultipleVaultDisplayMode()
}
}
}
return (
<Menu a11yLabel="Vault selection menu" isOpen>
<RadioButtonGroup
items={[
{ label: 'Multiple', value: 'many' },
{ label: 'One', value: 'single' },
]}
value={mode}
onChange={(value) => changeSelectionMode(value as SettingsMode)}
className="m-3 mt-1"
/>
{mode === 'many' && <ManyVaultSelectionMenu />}
{mode === 'single' && <SingleVaultSelectionMenu />}
</Menu>
)
}
export default observer(VaultSelectionMenu)

View File

@@ -0,0 +1,155 @@
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useRef, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import { KeyboardKey } from '@standardnotes/ui-services'
import Popover from '../Popover/Popover'
import { classNames, DecryptedItemInterface, VaultListingInterface } from '@standardnotes/snjs'
import { useApplication } from '../ApplicationProvider'
import MenuItem from '../Menu/MenuItem'
import Menu from '../Menu/Menu'
import { featureTrunkVaultsEnabled } from '@/FeatureTrunk'
type Props = {
iconClassName: string
items: DecryptedItemInterface[]
}
const AddToVaultMenuOption: FunctionComponent<Props> = ({ iconClassName, items }) => {
const application = useApplication()
const menuContainerRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
const vaults = application.vaults.getVaults()
const [isSubMenuOpen, setIsSubMenuOpen] = useState(false)
const toggleSubMenu = useCallback(() => {
setIsSubMenuOpen((isOpen) => !isOpen)
}, [])
const addItemsToVault = useCallback(
async (vault: VaultListingInterface) => {
if (application.vaults.isVaultLocked(vault)) {
const unlocked = await application.vaultDisplayService.unlockVault(vault)
if (!unlocked) {
return
}
}
for (const item of items) {
await application.vaults.moveItemToVault(vault, item)
}
},
[application.vaultDisplayService, application.vaults, items],
)
const removeItemsFromVault = useCallback(async () => {
for (const item of items) {
const vault = application.vaults.getItemVault(item)
if (!vault) {
continue
}
if (application.vaults.isVaultLocked(vault)) {
const unlocked = await application.vaultDisplayService.unlockVault(vault)
if (!unlocked) {
return
}
}
await application.vaults.removeItemFromVault(item)
}
}, [application.vaultDisplayService, application.vaults, items])
const doesVaultContainItems = (vault: VaultListingInterface) => {
return items.every((item) => item.key_system_identifier === vault.systemIdentifier)
}
const doSomeItemsBelongToVault = items.some((item) => application.vaults.isItemInVault(item))
const singleItemVault = items.length === 1 ? application.vaults.getItemVault(items[0]) : undefined
if (!featureTrunkVaultsEnabled()) {
return null
}
return (
<div ref={menuContainerRef}>
<MenuItem
className="justify-between"
onClick={toggleSubMenu}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
setIsSubMenuOpen(false)
}
}}
ref={buttonRef}
>
<div className="flex items-center">
<Icon type="safe-square" className={iconClassName} />
Move to vault
</div>
<Icon type="chevron-right" className="text-neutral" />
</MenuItem>
<Popover
title="Move to vault"
togglePopover={toggleSubMenu}
anchorElement={buttonRef.current}
open={isSubMenuOpen}
side="right"
align="start"
className="py-2"
overrideZIndex="z-modal"
>
<Menu a11yLabel="Vault selection menu" isOpen={isSubMenuOpen}>
{doSomeItemsBelongToVault && (
<MenuItem
onClick={() => {
void removeItemsFromVault()
}}
>
<span className="flex overflow-hidden overflow-ellipsis whitespace-nowrap">
<Icon type="close" className="mr-2 text-neutral" />
<div className="flex w-full items-center gap-1">
Move out of {singleItemVault ? singleItemVault.name : 'vaults'}
</div>
</span>
</MenuItem>
)}
{vaults.map((vault) => {
if (singleItemVault) {
return null
}
return (
<MenuItem
key={vault.uuid}
onClick={() => {
doesVaultContainItems(vault) ? void removeItemsFromVault() : void addItemsToVault(vault)
}}
>
<span
className={classNames(
'flex overflow-ellipsis whitespace-nowrap',
doesVaultContainItems(vault) ? 'font-bold' : '',
)}
>
<Icon
type="safe-square"
size="large"
className={`mr-2 text-neutral ${doesVaultContainItems(vault) ? 'text-info' : ''}`}
/>
<div className="flex w-full items-center">
{vault.name}
{application.vaults.isVaultLocked(vault) && <Icon className="ml-1" type="lock" size={'small'} />}
</div>
</span>
</MenuItem>
)
})}
</Menu>
</Popover>
</div>
)
}
export default observer(AddToVaultMenuOption)

View File

@@ -0,0 +1,20 @@
import { FunctionComponent } from 'react'
import Icon from '../Icon/Icon'
import { VaultListingInterface } from '@standardnotes/snjs'
type Props = {
vault: VaultListingInterface
}
const VaultNameBadge: FunctionComponent<Props> = ({ vault }) => {
return (
<div className={'rounded bg-success py-1 px-1.5 text-danger-contrast'}>
<span className="flex items-center" title="Vault name">
<Icon ariaLabel="Vault name" type="safe-square" className="mr-1 text-info-contrast" size="medium" />
<div className="text-center text-xs font-bold">{vault.name}</div>
</span>
</div>
)
}
export default VaultNameBadge