internal: incomplete vault systems behind feature flag (#2340)
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
"lint:fix": "eslint src/javascripts --fix",
|
||||
"start": "webpack-dev-server --config web.webpack.dev.js",
|
||||
"start-secure": "yarn start --server-type https",
|
||||
"test": "jest --config jest.config.js --coverage",
|
||||
"test": "jest --config jest.config.js",
|
||||
"tsc": "tsc --project tsconfig.json",
|
||||
"upgrade:snjs": "ncu -u '@standardnotes/*'",
|
||||
"watch": "webpack -w --config web.webpack.dev.js",
|
||||
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
assert,
|
||||
DesktopClientRequiresWebMethods,
|
||||
DesktopDeviceInterface,
|
||||
WebApplicationInterface,
|
||||
WebAppEvent,
|
||||
BackupServiceInterface,
|
||||
DesktopWatchedDirectoriesChanges,
|
||||
} from '@standardnotes/snjs'
|
||||
import { WebApplicationInterface } from '@standardnotes/ui-services'
|
||||
|
||||
export class DesktopManager
|
||||
extends ApplicationService
|
||||
@@ -175,7 +175,7 @@ export class DesktopManager
|
||||
return
|
||||
}
|
||||
|
||||
const updatedComponent = await this.application.mutator.changeAndSaveItem(
|
||||
const updatedComponent = await this.application.changeAndSaveItem(
|
||||
component,
|
||||
(m) => {
|
||||
const mutator = m as ComponentMutator
|
||||
|
||||
@@ -108,29 +108,46 @@ export abstract class WebOrDesktopDevice implements WebOrDesktopDeviceInterface
|
||||
identifier: string,
|
||||
): Promise<DatabaseFullEntryLoadChunkResponse> {
|
||||
const entries = await this.getAllDatabaseEntries(identifier)
|
||||
const sorted = GetSortedPayloadsByPriority(entries, options)
|
||||
|
||||
const {
|
||||
itemsKeyPayloads,
|
||||
keySystemRootKeyPayloads,
|
||||
keySystemItemsKeyPayloads,
|
||||
contentTypePriorityPayloads,
|
||||
remainingPayloads,
|
||||
} = GetSortedPayloadsByPriority(entries, options)
|
||||
|
||||
const itemsKeysChunk: DatabaseFullEntryLoadChunk = {
|
||||
entries: sorted.itemsKeyPayloads,
|
||||
entries: itemsKeyPayloads,
|
||||
}
|
||||
|
||||
const keySystemRootKeysChunk: DatabaseFullEntryLoadChunk = {
|
||||
entries: keySystemRootKeyPayloads,
|
||||
}
|
||||
|
||||
const keySystemItemsKeysChunk: DatabaseFullEntryLoadChunk = {
|
||||
entries: keySystemItemsKeyPayloads,
|
||||
}
|
||||
|
||||
const contentTypePriorityChunk: DatabaseFullEntryLoadChunk = {
|
||||
entries: sorted.contentTypePriorityPayloads,
|
||||
entries: contentTypePriorityPayloads,
|
||||
}
|
||||
|
||||
const remainingPayloadsChunks: DatabaseFullEntryLoadChunk[] = []
|
||||
for (let i = 0; i < sorted.remainingPayloads.length; i += options.batchSize) {
|
||||
for (let i = 0; i < remainingPayloads.length; i += options.batchSize) {
|
||||
remainingPayloadsChunks.push({
|
||||
entries: sorted.remainingPayloads.slice(i, i + options.batchSize),
|
||||
entries: remainingPayloads.slice(i, i + options.batchSize),
|
||||
})
|
||||
}
|
||||
|
||||
const result: DatabaseFullEntryLoadChunkResponse = {
|
||||
fullEntries: {
|
||||
itemsKeys: itemsKeysChunk,
|
||||
keySystemRootKeys: keySystemRootKeysChunk,
|
||||
keySystemItemsKeys: keySystemItemsKeysChunk,
|
||||
remainingChunks: [contentTypePriorityChunk, ...remainingPayloadsChunks],
|
||||
},
|
||||
remainingChunksItemCount: sorted.contentTypePriorityPayloads.length + sorted.remainingPayloads.length,
|
||||
remainingChunksItemCount: contentTypePriorityPayloads.length + remainingPayloads.length,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
ContentType,
|
||||
DecryptedItemInterface,
|
||||
WebAppEvent,
|
||||
WebApplicationInterface,
|
||||
MobileDeviceInterface,
|
||||
MobileUnlockTiming,
|
||||
DecryptedItem,
|
||||
@@ -27,7 +26,7 @@ import {
|
||||
import { makeObservable, observable } from 'mobx'
|
||||
import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
|
||||
import { PanelResizedData } from '@/Types/PanelResizedData'
|
||||
import { isAndroid, isDesktopApplication, isIOS } from '@/Utils'
|
||||
import { isAndroid, isDesktopApplication, isDev, isIOS } from '@/Utils'
|
||||
import { DesktopManager } from './Device/DesktopManager'
|
||||
import {
|
||||
ArchiveManager,
|
||||
@@ -38,7 +37,10 @@ import {
|
||||
RouteService,
|
||||
RouteServiceInterface,
|
||||
ThemeManager,
|
||||
VaultDisplayService,
|
||||
VaultDisplayServiceInterface,
|
||||
WebAlertService,
|
||||
WebApplicationInterface,
|
||||
} from '@standardnotes/ui-services'
|
||||
import { MobileWebReceiver, NativeMobileEventListener } from '../NativeMobileWeb/MobileWebReceiver'
|
||||
import { AndroidBackHandler } from '@/NativeMobileWeb/AndroidBackHandler'
|
||||
@@ -49,6 +51,7 @@ import { FeatureName } from '@/Controllers/FeatureName'
|
||||
import { ItemGroupController } from '@/Components/NoteView/Controller/ItemGroupController'
|
||||
import { VisibilityObserver } from './VisibilityObserver'
|
||||
import { MomentsService } from '@/Controllers/Moments/MomentsService'
|
||||
import { purchaseMockSubscription } from '@/Utils/Dev/PurchaseMockSubscription'
|
||||
|
||||
export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void
|
||||
|
||||
@@ -114,6 +117,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
||||
this.webServices.viewControllerManager.filesController,
|
||||
this.internalEventBus,
|
||||
)
|
||||
this.webServices.vaultDisplayService = new VaultDisplayService(this, this.internalEventBus)
|
||||
|
||||
if (this.isNativeMobileWeb()) {
|
||||
this.mobileWebReceiver = new MobileWebReceiver(this)
|
||||
@@ -194,6 +198,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
||||
this.notifyWebEvent(WebAppEvent.PanelResized, data)
|
||||
}
|
||||
|
||||
public get vaultDisplayService(): VaultDisplayServiceInterface {
|
||||
return this.webServices.vaultDisplayService
|
||||
}
|
||||
|
||||
public getViewControllerManager(): ViewControllerManager {
|
||||
return this.webServices.viewControllerManager
|
||||
}
|
||||
@@ -450,4 +458,12 @@ export class WebApplication extends SNApplication implements WebApplicationInter
|
||||
generateUUID(): string {
|
||||
return this.options.crypto.generateUUID()
|
||||
}
|
||||
|
||||
dev__purchaseMockSubscription() {
|
||||
if (!isDev) {
|
||||
throw new Error('This method is only available in dev mode')
|
||||
}
|
||||
|
||||
void purchaseMockSubscription(this.getUser()?.email as string, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ChangelogServiceInterface,
|
||||
KeyboardService,
|
||||
ThemeManager,
|
||||
VaultDisplayServiceInterface,
|
||||
} from '@standardnotes/ui-services'
|
||||
import { MomentsService } from '@/Controllers/Moments/MomentsService'
|
||||
|
||||
@@ -18,4 +19,5 @@ export type WebServices = {
|
||||
keyboardService: KeyboardService
|
||||
changelogService: ChangelogServiceInterface
|
||||
momentsService: MomentsService
|
||||
vaultDisplayService: VaultDisplayServiceInterface
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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[]
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -45,6 +45,7 @@ const TagsSection: FunctionComponent<Props> = ({ viewControllerManager }) => {
|
||||
viewControllerManager.application.mutator
|
||||
.migrateTagsToFolders()
|
||||
.then(() => {
|
||||
void viewControllerManager.application.sync.sync()
|
||||
checkIfMigrationNeeded()
|
||||
})
|
||||
.catch(console.error)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
import { WebApplication } from '@/Application/WebApplication'
|
||||
import { ShouldPersistNoteStateKey } from '@/Components/Preferences/Panes/General/Persistence'
|
||||
import { ApplicationEvent, ContentType, InternalEventBus } from '@standardnotes/snjs'
|
||||
import { ApplicationEvent, ContentType, InternalEventBusInterface } from '@standardnotes/snjs'
|
||||
import { PersistedStateValue, StorageKey } from '@standardnotes/ui-services'
|
||||
import { CrossControllerEvent } from '../CrossControllerEvent'
|
||||
|
||||
@@ -8,7 +8,7 @@ export class PersistenceService {
|
||||
private unsubAppEventObserver: () => void
|
||||
private didHydrateOnce = false
|
||||
|
||||
constructor(private application: WebApplication, private eventBus: InternalEventBus) {
|
||||
constructor(private application: WebApplication, private eventBus: InternalEventBusInterface) {
|
||||
this.unsubAppEventObserver = this.application.addEventObserver(async (eventName) => {
|
||||
if (!this.application) {
|
||||
return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { destroyAllObjectProperties, isDev } from '@/Utils'
|
||||
import { action, computed, makeObservable, observable, runInAction } from 'mobx'
|
||||
import { ApplicationEvent, ContentType, InternalEventBus, SNNote, SNTag } from '@standardnotes/snjs'
|
||||
import { ApplicationEvent, ContentType, InternalEventBusInterface, SNNote, SNTag } from '@standardnotes/snjs'
|
||||
import { WebApplication } from '@/Application/WebApplication'
|
||||
import { AccountMenuPane } from '@/Components/AccountMenu/AccountMenuPane'
|
||||
import { AbstractViewController } from '../Abstract/AbstractViewController'
|
||||
@@ -28,7 +28,7 @@ export class AccountMenuController extends AbstractViewController {
|
||||
destroyAllObjectProperties(this)
|
||||
}
|
||||
|
||||
constructor(application: WebApplication, eventBus: InternalEventBus) {
|
||||
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
|
||||
super(application, eventBus)
|
||||
|
||||
makeObservable(this, {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ApplicationEvent,
|
||||
FeatureIdentifier,
|
||||
FeatureStatus,
|
||||
InternalEventBus,
|
||||
InternalEventBusInterface,
|
||||
InternalEventInterface,
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, makeObservable, observable, runInAction, when } from 'mobx'
|
||||
@@ -33,7 +33,7 @@ export class FeaturesController extends AbstractViewController {
|
||||
destroyAllObjectProperties(this)
|
||||
}
|
||||
|
||||
constructor(application: WebApplication, eventBus: InternalEventBus) {
|
||||
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
|
||||
super(application, eventBus)
|
||||
|
||||
this.hasFolders = this.isEntitledToFolders()
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
ClientDisplayableError,
|
||||
ContentType,
|
||||
FileItem,
|
||||
InternalEventBus,
|
||||
InternalEventBusInterface,
|
||||
isFile,
|
||||
Platform,
|
||||
} from '@standardnotes/snjs'
|
||||
@@ -68,7 +68,7 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
||||
application: WebApplication,
|
||||
private notesController: NotesController,
|
||||
private filePreviewModalController: FilePreviewModalController,
|
||||
eventBus: InternalEventBus,
|
||||
eventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(application, eventBus)
|
||||
|
||||
@@ -156,7 +156,8 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
||||
return
|
||||
}
|
||||
|
||||
await this.application.items.associateFileWithNote(file, note)
|
||||
await this.application.mutator.associateFileWithNote(file, note)
|
||||
void this.application.sync.sync()
|
||||
}
|
||||
|
||||
detachFileFromNote = async (file: FileItem) => {
|
||||
@@ -168,16 +169,18 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
||||
})
|
||||
return
|
||||
}
|
||||
await this.application.items.disassociateFileWithNote(file, note)
|
||||
await this.application.mutator.disassociateFileWithNote(file, note)
|
||||
void this.application.sync.sync()
|
||||
}
|
||||
|
||||
toggleFileProtection = async (file: FileItem) => {
|
||||
let result: FileItem | undefined
|
||||
if (file.protected) {
|
||||
result = await this.application.mutator.unprotectFile(file)
|
||||
result = await this.application.protections.unprotectFile(file)
|
||||
} else {
|
||||
result = await this.application.mutator.protectFile(file)
|
||||
result = await this.application.protections.protectFile(file)
|
||||
}
|
||||
void this.application.sync.sync()
|
||||
const isProtected = result ? result.protected : file.protected
|
||||
return isProtected
|
||||
}
|
||||
@@ -189,7 +192,8 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
||||
}
|
||||
|
||||
renameFile = async (file: FileItem, fileName: string) => {
|
||||
await this.application.items.renameFile(file, fileName)
|
||||
await this.application.mutator.renameFile(file, fileName)
|
||||
void this.application.sync.sync()
|
||||
}
|
||||
|
||||
handleFileAction = async (
|
||||
@@ -373,7 +377,10 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
||||
return
|
||||
}
|
||||
|
||||
const operation = await this.application.files.beginNewFileUpload(fileToUpload.size)
|
||||
const operation = await this.application.files.beginNewFileUpload(
|
||||
fileToUpload.size,
|
||||
this.application.vaultDisplayService.exclusivelyShownVault,
|
||||
)
|
||||
|
||||
if (operation instanceof ClientDisplayableError) {
|
||||
addToast({
|
||||
@@ -485,12 +492,12 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
|
||||
|
||||
setProtectionForFiles = async (protect: boolean, files: FileItem[]) => {
|
||||
if (protect) {
|
||||
const protectedItems = await this.application.mutator.protectItems(files)
|
||||
const protectedItems = await this.application.protections.protectItems(files)
|
||||
if (protectedItems) {
|
||||
this.setShowProtectedOverlay(true)
|
||||
}
|
||||
} else {
|
||||
const unprotectedItems = await this.application.mutator.unprotectItems(files, ChallengeReason.UnprotectFile)
|
||||
const unprotectedItems = await this.application.protections.unprotectItems(files, ChallengeReason.UnprotectFile)
|
||||
if (unprotectedItems) {
|
||||
this.setShowProtectedOverlay(false)
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ export class ImportModalController {
|
||||
}
|
||||
}
|
||||
const currentDate = new Date()
|
||||
const importTagItem = this.application.mutator.createTemplateItem<TagContent, SNTag>(ContentType.Tag, {
|
||||
const importTagItem = this.application.items.createTemplateItem<TagContent, SNTag>(ContentType.Tag, {
|
||||
title: `Imported on ${currentDate.toLocaleString()}`,
|
||||
expanded: false,
|
||||
iconString: '',
|
||||
|
||||
@@ -2,7 +2,6 @@ import { SNTag } from '@standardnotes/snjs'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { InternalEventBus } from '@standardnotes/services'
|
||||
import { WebApplication } from '@/Application/WebApplication'
|
||||
import { LinkingController } from '../LinkingController'
|
||||
import { NavigationController } from '../Navigation/NavigationController'
|
||||
import { NotesController } from '../NotesController/NotesController'
|
||||
import { SearchOptionsController } from '../SearchOptionsController'
|
||||
@@ -28,7 +27,6 @@ describe('item list controller', () => {
|
||||
|
||||
const searchOptionsController = {} as jest.Mocked<SearchOptionsController>
|
||||
const notesController = {} as jest.Mocked<NotesController>
|
||||
const linkingController = {} as jest.Mocked<LinkingController>
|
||||
const eventBus = new InternalEventBus()
|
||||
|
||||
controller = new ItemListController(
|
||||
@@ -37,7 +35,6 @@ describe('item list controller', () => {
|
||||
searchOptionsController,
|
||||
selectionController,
|
||||
notesController,
|
||||
linkingController,
|
||||
eventBus,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
SNNote,
|
||||
SNTag,
|
||||
SystemViewId,
|
||||
DisplayOptions,
|
||||
InternalEventBus,
|
||||
InternalEventHandlerInterface,
|
||||
InternalEventInterface,
|
||||
FileItem,
|
||||
@@ -22,6 +20,8 @@ import {
|
||||
isFile,
|
||||
isSmartView,
|
||||
isSystemView,
|
||||
NotesAndFilesDisplayControllerOptions,
|
||||
InternalEventBusInterface,
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
|
||||
import { WebApplication } from '../../Application/WebApplication'
|
||||
@@ -33,16 +33,18 @@ import { SelectedItemsController } from '../SelectedItemsController'
|
||||
import { NotesController } from '../NotesController/NotesController'
|
||||
import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
|
||||
import { PrefDefaults } from '@/Constants/PrefDefaults'
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import dayjsAdvancedFormat from 'dayjs/plugin/advancedFormat'
|
||||
dayjs.extend(dayjsAdvancedFormat)
|
||||
import { LinkingController } from '../LinkingController'
|
||||
|
||||
import { AbstractViewController } from '../Abstract/AbstractViewController'
|
||||
import { log, LoggingDomain } from '@/Logging'
|
||||
import { NoteViewController } from '@/Components/NoteView/Controller/NoteViewController'
|
||||
import { FileViewController } from '@/Components/NoteView/Controller/FileViewController'
|
||||
import { TemplateNoteViewAutofocusBehavior } from '@/Components/NoteView/Controller/TemplateNoteViewControllerOptions'
|
||||
import { ItemsReloadSource } from './ItemsReloadSource'
|
||||
import { VaultDisplayServiceEvent } from '@standardnotes/ui-services'
|
||||
|
||||
const MinNoteCellHeight = 51.0
|
||||
const DefaultListNumNotes = 20
|
||||
@@ -59,7 +61,7 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
renderedItems: ListableContentItem[] = []
|
||||
searchSubmitted = false
|
||||
showDisplayOptionsMenu = false
|
||||
displayOptions: DisplayOptions = {
|
||||
displayOptions: NotesAndFilesDisplayControllerOptions = {
|
||||
sortBy: CollectionSort.CreatedAt,
|
||||
sortDirection: 'dsc',
|
||||
includePinned: true,
|
||||
@@ -96,13 +98,13 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
private searchOptionsController: SearchOptionsController,
|
||||
private selectionController: SelectedItemsController,
|
||||
private notesController: NotesController,
|
||||
private linkingController: LinkingController,
|
||||
eventBus: InternalEventBus,
|
||||
eventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(application, eventBus)
|
||||
|
||||
eventBus.addEventHandler(this, CrossControllerEvent.TagChanged)
|
||||
eventBus.addEventHandler(this, CrossControllerEvent.ActiveEditorChanged)
|
||||
eventBus.addEventHandler(this, VaultDisplayServiceEvent.VaultDisplayOptionsChanged)
|
||||
|
||||
this.resetPagination()
|
||||
|
||||
@@ -222,6 +224,8 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
await this.handleTagChange(payload.userTriggered)
|
||||
} else if (event.type === CrossControllerEvent.ActiveEditorChanged) {
|
||||
this.handleEditorChange().catch(console.error)
|
||||
} else if (event.type === VaultDisplayServiceEvent.VaultDisplayOptionsChanged) {
|
||||
void this.reloadItems(ItemsReloadSource.DisplayOptionsChange)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,7 +493,7 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
includeTrashed = this.displayOptions.includeTrashed ?? false
|
||||
}
|
||||
|
||||
const criteria: DisplayOptions = {
|
||||
const criteria: NotesAndFilesDisplayControllerOptions = {
|
||||
sortBy: this.displayOptions.sortBy,
|
||||
sortDirection: this.displayOptions.sortDirection,
|
||||
tags: tag instanceof SNTag ? [tag] : [],
|
||||
@@ -512,8 +516,9 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
}: {
|
||||
userTriggered: boolean
|
||||
}): Promise<{ didReloadItems: boolean }> => {
|
||||
const newDisplayOptions = {} as DisplayOptions
|
||||
const newDisplayOptions = {} as NotesAndFilesDisplayControllerOptions
|
||||
const newWebDisplayOptions = {} as WebDisplayOptions
|
||||
|
||||
const selectedTag = this.navigationController.selected
|
||||
const isSystemTag = selectedTag && isSmartView(selectedTag) && isSystemView(selectedTag)
|
||||
const selectedTagPreferences = isSystemTag
|
||||
@@ -631,6 +636,7 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
tag: activeRegularTagUuid,
|
||||
createdAt,
|
||||
autofocusBehavior,
|
||||
vault: this.application.vaultDisplayService.exclusivelyShownVault,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -783,7 +789,7 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
const activeNote = this.application.itemControllerGroup.activeItemViewController?.item
|
||||
|
||||
if (activeNote && activeNote.conflictOf) {
|
||||
this.application.mutator
|
||||
this.application
|
||||
.changeAndSaveItem(activeNote, (mutator) => {
|
||||
mutator.conflictOf = undefined
|
||||
})
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
FileToNoteReference,
|
||||
InternalEventBus,
|
||||
SNNote,
|
||||
ItemsClientInterface,
|
||||
ItemManagerInterface,
|
||||
VaultListingInterface,
|
||||
ItemInterface,
|
||||
InternalFeatureService,
|
||||
InternalFeature,
|
||||
} from '@standardnotes/snjs'
|
||||
import { FilesController } from './FilesController'
|
||||
import { ItemListController } from './ItemList/ItemListController'
|
||||
@@ -53,12 +57,20 @@ describe('LinkingController', () => {
|
||||
let subscriptionController: SubscriptionController
|
||||
|
||||
beforeEach(() => {
|
||||
application = {} as jest.Mocked<WebApplication>
|
||||
application = {
|
||||
vaults: {} as jest.Mocked<WebApplication['vaults']>,
|
||||
alerts: {} as jest.Mocked<WebApplication['alerts']>,
|
||||
sync: {} as jest.Mocked<WebApplication['sync']>,
|
||||
mutator: {} as jest.Mocked<WebApplication['mutator']>,
|
||||
} as unknown as jest.Mocked<WebApplication>
|
||||
|
||||
application.getPreference = jest.fn()
|
||||
application.addSingleEventObserver = jest.fn()
|
||||
application.streamItems = jest.fn()
|
||||
application.itemControllerGroup = {} as jest.Mocked<WebApplication['itemControllerGroup']>
|
||||
application.sync.sync = jest.fn()
|
||||
|
||||
Object.defineProperty(application, 'items', { value: {} as jest.Mocked<ItemsClientInterface> })
|
||||
Object.defineProperty(application, 'items', { value: {} as jest.Mocked<ItemManagerInterface> })
|
||||
|
||||
navigationController = {} as jest.Mocked<NavigationController>
|
||||
|
||||
@@ -181,38 +193,73 @@ describe('LinkingController', () => {
|
||||
})
|
||||
|
||||
it('should be true if active item & result are different content type & active item references result', () => {
|
||||
const activeFile = createNote('test', {
|
||||
uuid: 'active-file',
|
||||
const activeNote = createNote('test', {
|
||||
uuid: 'active-note',
|
||||
references: [
|
||||
{
|
||||
reference_type: ContentReferenceType.FileToNote,
|
||||
uuid: 'note-result',
|
||||
uuid: 'file-result',
|
||||
} as FileToNoteReference,
|
||||
],
|
||||
})
|
||||
|
||||
const noteResult = createFile('test', {
|
||||
uuid: 'note-result',
|
||||
const fileResult = createFile('test', {
|
||||
uuid: 'file-result',
|
||||
references: [],
|
||||
})
|
||||
|
||||
const isNoteResultAlreadyLinked = isSearchResultAlreadyLinkedToItem(noteResult, activeFile)
|
||||
const isNoteResultAlreadyLinked = isSearchResultAlreadyLinkedToItem(fileResult, activeNote)
|
||||
expect(isNoteResultAlreadyLinked).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should be false if active item & result are different content type & neither references the other', () => {
|
||||
const activeFile = createNote('test', {
|
||||
const activeNote = createNote('test', {
|
||||
uuid: 'active-file',
|
||||
references: [],
|
||||
})
|
||||
|
||||
const noteResult = createFile('test', {
|
||||
const fileResult = createFile('test', {
|
||||
uuid: 'note-result',
|
||||
references: [],
|
||||
})
|
||||
|
||||
const isNoteResultAlreadyLinked = isSearchResultAlreadyLinkedToItem(noteResult, activeFile)
|
||||
const isNoteResultAlreadyLinked = isSearchResultAlreadyLinkedToItem(fileResult, activeNote)
|
||||
expect(isNoteResultAlreadyLinked).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkItems', () => {
|
||||
it('should move file to same vault as note if file does not belong to any vault', async () => {
|
||||
InternalFeatureService.get().enableFeature(InternalFeature.Vaults)
|
||||
|
||||
application.mutator.associateFileWithNote = jest.fn().mockReturnValue({})
|
||||
|
||||
const moveToVaultSpy = (application.vaults.moveItemToVault = jest.fn())
|
||||
|
||||
const note = createNote('test', {
|
||||
uuid: 'note',
|
||||
references: [],
|
||||
})
|
||||
|
||||
const file = createFile('test', {
|
||||
uuid: 'file',
|
||||
references: [],
|
||||
})
|
||||
|
||||
const noteVault = {
|
||||
uuid: 'note-vault',
|
||||
} as jest.Mocked<VaultListingInterface>
|
||||
|
||||
application.vaults.getItemVault = jest.fn().mockImplementation((item: ItemInterface) => {
|
||||
if (item.uuid === note.uuid) {
|
||||
return noteVault
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
await linkingController.linkItems(note, file)
|
||||
|
||||
expect(moveToVaultSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { WebApplication } from '@/Application/WebApplication'
|
||||
import { FileItemActionType } from '@/Components/AttachedFilesPopover/PopoverFileItemAction'
|
||||
import { NoteViewController } from '@/Components/NoteView/Controller/NoteViewController'
|
||||
import { AppPaneId } from '@/Components/Panes/AppPaneMetadata'
|
||||
@@ -9,13 +8,14 @@ import {
|
||||
ApplicationEvent,
|
||||
ContentType,
|
||||
FileItem,
|
||||
InternalEventBus,
|
||||
naturalSort,
|
||||
PrefKey,
|
||||
SNNote,
|
||||
SNTag,
|
||||
isFile,
|
||||
isNote,
|
||||
InternalEventBusInterface,
|
||||
isTag,
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, computed, makeObservable, observable } from 'mobx'
|
||||
import { AbstractViewController } from './Abstract/AbstractViewController'
|
||||
@@ -25,6 +25,8 @@ import { ItemListController } from './ItemList/ItemListController'
|
||||
import { NavigationController } from './Navigation/NavigationController'
|
||||
import { SelectedItemsController } from './SelectedItemsController'
|
||||
import { SubscriptionController } from './Subscription/SubscriptionController'
|
||||
import { WebApplication } from '@/Application/WebApplication'
|
||||
import { featureTrunkVaultsEnabled } from '@/FeatureTrunk'
|
||||
|
||||
export class LinkingController extends AbstractViewController {
|
||||
shouldLinkToParentFolders: boolean
|
||||
@@ -37,7 +39,7 @@ export class LinkingController extends AbstractViewController {
|
||||
application: WebApplication,
|
||||
private navigationController: NavigationController,
|
||||
private selectionController: SelectedItemsController,
|
||||
eventBus: InternalEventBus,
|
||||
eventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(application, eventBus)
|
||||
|
||||
@@ -165,7 +167,11 @@ export class LinkingController extends AbstractViewController {
|
||||
}
|
||||
|
||||
unlinkItems = async (item: LinkableItem, itemToUnlink: LinkableItem) => {
|
||||
await this.application.items.unlinkItems(item, itemToUnlink)
|
||||
try {
|
||||
await this.application.mutator.unlinkItems(item, itemToUnlink)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
void this.application.sync.sync()
|
||||
}
|
||||
@@ -188,8 +194,36 @@ export class LinkingController extends AbstractViewController {
|
||||
}
|
||||
|
||||
linkItems = async (item: LinkableItem, itemToLink: LinkableItem) => {
|
||||
if (item instanceof SNNote) {
|
||||
if (itemToLink instanceof SNNote && !this.isEntitledToNoteLinking) {
|
||||
const linkNoteAndFile = async (note: SNNote, file: FileItem) => {
|
||||
const updatedFile = await this.application.mutator.associateFileWithNote(file, note)
|
||||
|
||||
if (updatedFile && featureTrunkVaultsEnabled()) {
|
||||
const noteVault = this.application.vaults.getItemVault(note)
|
||||
const fileVault = this.application.vaults.getItemVault(updatedFile)
|
||||
if (noteVault && !fileVault) {
|
||||
await this.application.vaults.moveItemToVault(noteVault, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const linkFileAndFile = async (file1: FileItem, file2: FileItem) => {
|
||||
await this.application.mutator.linkFileToFile(file1, file2)
|
||||
}
|
||||
|
||||
const linkNoteToNote = async (note1: SNNote, note2: SNNote) => {
|
||||
await this.application.mutator.linkNoteToNote(note1, note2)
|
||||
}
|
||||
|
||||
const linkTagToNote = async (tag: SNTag, note: SNNote) => {
|
||||
await this.addTagToItem(tag, note)
|
||||
}
|
||||
|
||||
const linkTagToFile = async (tag: SNTag, file: FileItem) => {
|
||||
await this.addTagToItem(tag, file)
|
||||
}
|
||||
|
||||
if (isNote(item)) {
|
||||
if (isNote(itemToLink) && !this.isEntitledToNoteLinking) {
|
||||
void this.publishCrossControllerEventSync(CrossControllerEvent.DisplayPremiumModal, {
|
||||
featureName: 'Note linking',
|
||||
})
|
||||
@@ -200,22 +234,22 @@ export class LinkingController extends AbstractViewController {
|
||||
await this.ensureActiveItemIsInserted()
|
||||
}
|
||||
|
||||
if (itemToLink instanceof FileItem) {
|
||||
await this.application.items.associateFileWithNote(itemToLink, item)
|
||||
} else if (itemToLink instanceof SNNote) {
|
||||
await this.application.items.linkNoteToNote(item, itemToLink)
|
||||
} else if (itemToLink instanceof SNTag) {
|
||||
await this.addTagToItem(itemToLink, item)
|
||||
if (isFile(itemToLink)) {
|
||||
await linkNoteAndFile(item, itemToLink)
|
||||
} else if (isNote(itemToLink)) {
|
||||
await linkNoteToNote(item, itemToLink)
|
||||
} else if (isTag(itemToLink)) {
|
||||
await linkTagToNote(itemToLink, item)
|
||||
} else {
|
||||
throw Error('Invalid item type')
|
||||
}
|
||||
} else if (item instanceof FileItem) {
|
||||
if (itemToLink instanceof SNNote) {
|
||||
await this.application.items.associateFileWithNote(item, itemToLink)
|
||||
} else if (itemToLink instanceof FileItem) {
|
||||
await this.application.items.linkFileToFile(item, itemToLink)
|
||||
} else if (itemToLink instanceof SNTag) {
|
||||
await this.addTagToItem(itemToLink, item)
|
||||
} else if (isFile(item)) {
|
||||
if (isNote(itemToLink)) {
|
||||
await linkNoteAndFile(itemToLink, item)
|
||||
} else if (isFile(itemToLink)) {
|
||||
await linkFileAndFile(item, itemToLink)
|
||||
} else if (isTag(itemToLink)) {
|
||||
await linkTagToFile(itemToLink, item)
|
||||
} else {
|
||||
throw Error('Invalid item to link')
|
||||
}
|
||||
@@ -248,8 +282,12 @@ export class LinkingController extends AbstractViewController {
|
||||
|
||||
createAndAddNewTag = async (title: string): Promise<SNTag> => {
|
||||
await this.ensureActiveItemIsInserted()
|
||||
|
||||
const vault = this.application.vaultDisplayService.exclusivelyShownVault
|
||||
|
||||
const newTag = await this.application.mutator.findOrCreateTag(title, vault)
|
||||
|
||||
const activeItem = this.activeItem
|
||||
const newTag = await this.application.mutator.findOrCreateTag(title)
|
||||
if (activeItem) {
|
||||
await this.addTagToItem(newTag, activeItem)
|
||||
}
|
||||
@@ -259,9 +297,9 @@ export class LinkingController extends AbstractViewController {
|
||||
|
||||
addTagToItem = async (tag: SNTag, item: FileItem | SNNote) => {
|
||||
if (item instanceof SNNote) {
|
||||
await this.application.items.addTagToNote(item, tag, this.shouldLinkToParentFolders)
|
||||
await this.application.mutator.addTagToNote(item, tag, this.shouldLinkToParentFolders)
|
||||
} else if (item instanceof FileItem) {
|
||||
await this.application.items.addTagToFile(item, tag, this.shouldLinkToParentFolders)
|
||||
await this.application.mutator.addTagToFile(item, tag, this.shouldLinkToParentFolders)
|
||||
}
|
||||
|
||||
this.application.sync.sync().catch(console.error)
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { confirmDialog, CREATE_NEW_TAG_COMMAND, NavigationControllerPersistableValue } from '@standardnotes/ui-services'
|
||||
import {
|
||||
confirmDialog,
|
||||
CREATE_NEW_TAG_COMMAND,
|
||||
NavigationControllerPersistableValue,
|
||||
VaultDisplayServiceEvent,
|
||||
} from '@standardnotes/ui-services'
|
||||
import { STRING_DELETE_TAG } from '@/Constants/Strings'
|
||||
import { MAX_MENU_SIZE_MULTIPLIER, MENU_MARGIN_FROM_APP_BORDER, SMART_TAGS_FEATURE_NAME } from '@/Constants/Constants'
|
||||
import {
|
||||
@@ -10,13 +15,15 @@ import {
|
||||
isSystemView,
|
||||
FindItem,
|
||||
SystemViewId,
|
||||
InternalEventBus,
|
||||
InternalEventPublishStrategy,
|
||||
VectorIconNameOrEmoji,
|
||||
isTag,
|
||||
PrefKey,
|
||||
InternalEventBusInterface,
|
||||
InternalEventHandlerInterface,
|
||||
InternalEventInterface,
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, computed, makeAutoObservable, makeObservable, observable, reaction, runInAction } from 'mobx'
|
||||
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
|
||||
import { WebApplication } from '../../Application/WebApplication'
|
||||
import { FeaturesController } from '../FeaturesController'
|
||||
import { destroyAllObjectProperties } from '@/Utils'
|
||||
@@ -27,10 +34,11 @@ import { AbstractViewController } from '../Abstract/AbstractViewController'
|
||||
import { Persistable } from '../Abstract/Persistable'
|
||||
import { TagListSectionType } from '@/Components/Tags/TagListSection'
|
||||
import { PaneLayout } from '../PaneController/PaneLayout'
|
||||
import { TagsCountsState } from './TagsCountsState'
|
||||
|
||||
export class NavigationController
|
||||
extends AbstractViewController
|
||||
implements Persistable<NavigationControllerPersistableValue>
|
||||
implements Persistable<NavigationControllerPersistableValue>, InternalEventHandlerInterface
|
||||
{
|
||||
tags: SNTag[] = []
|
||||
smartViews: SmartView[] = []
|
||||
@@ -54,9 +62,15 @@ export class NavigationController
|
||||
|
||||
private readonly tagsCountsState: TagsCountsState
|
||||
|
||||
constructor(application: WebApplication, private featuresController: FeaturesController, eventBus: InternalEventBus) {
|
||||
constructor(
|
||||
application: WebApplication,
|
||||
private featuresController: FeaturesController,
|
||||
eventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(application, eventBus)
|
||||
|
||||
eventBus.addEventHandler(this, VaultDisplayServiceEvent.VaultDisplayOptionsChanged)
|
||||
|
||||
this.tagsCountsState = new TagsCountsState(this.application)
|
||||
this.smartViews = this.application.items.getSmartViews()
|
||||
|
||||
@@ -109,11 +123,9 @@ export class NavigationController
|
||||
|
||||
this.disposers.push(
|
||||
this.application.streamItems([ContentType.Tag, ContentType.SmartView], ({ changed, removed }) => {
|
||||
runInAction(() => {
|
||||
this.tags = this.application.items.getDisplayableTags()
|
||||
this.starredTags = this.tags.filter((tag) => tag.starred)
|
||||
this.smartViews = this.application.items.getSmartViews()
|
||||
this.reloadTags()
|
||||
|
||||
runInAction(() => {
|
||||
const currentSelectedTag = this.selected_
|
||||
|
||||
if (!currentSelectedTag) {
|
||||
@@ -173,6 +185,20 @@ export class NavigationController
|
||||
)
|
||||
}
|
||||
|
||||
private reloadTags(): void {
|
||||
runInAction(() => {
|
||||
this.tags = this.application.items.getDisplayableTags()
|
||||
this.starredTags = this.tags.filter((tag) => tag.starred)
|
||||
this.smartViews = this.application.items.getSmartViews()
|
||||
})
|
||||
}
|
||||
|
||||
async handleEvent(event: InternalEventInterface): Promise<void> {
|
||||
if (event.type === VaultDisplayServiceEvent.VaultDisplayOptionsChanged) {
|
||||
this.reloadTags()
|
||||
}
|
||||
}
|
||||
|
||||
private findAndSetTag = (uuid: UuidString) => {
|
||||
const tagToSelect = [...this.tags, ...this.smartViews].find((tag) => tag.uuid === uuid)
|
||||
if (tagToSelect) {
|
||||
@@ -232,7 +258,10 @@ export class NavigationController
|
||||
return
|
||||
}
|
||||
|
||||
const createdTag = (await this.application.mutator.createTagOrSmartView(title)) as SNTag
|
||||
const createdTag = await this.application.mutator.createTagOrSmartView<SNTag>(
|
||||
title,
|
||||
this.application.vaultDisplayService.exclusivelyShownVault,
|
||||
)
|
||||
|
||||
const futureSiblings = this.application.items.getTagChildren(parent)
|
||||
|
||||
@@ -454,7 +483,7 @@ export class NavigationController
|
||||
}
|
||||
|
||||
public async setPanelWidthForTag(tag: SNTag, width: number): Promise<void> {
|
||||
await this.application.mutator.changeAndSaveItem<TagMutator>(tag, (mutator) => {
|
||||
await this.application.changeAndSaveItem<TagMutator>(tag, (mutator) => {
|
||||
mutator.preferences = {
|
||||
...mutator.preferences,
|
||||
panelWidth: width,
|
||||
@@ -468,7 +497,7 @@ export class NavigationController
|
||||
{ userTriggered } = { userTriggered: false },
|
||||
) {
|
||||
if (tag && tag.conflictOf) {
|
||||
this.application.mutator
|
||||
this.application
|
||||
.changeAndSaveItem(tag, (mutator) => {
|
||||
mutator.conflictOf = undefined
|
||||
})
|
||||
@@ -529,7 +558,7 @@ export class NavigationController
|
||||
return
|
||||
}
|
||||
|
||||
this.application.mutator
|
||||
this.application
|
||||
.changeAndSaveItem<TagMutator>(tag, (mutator) => {
|
||||
mutator.expanded = expanded
|
||||
})
|
||||
@@ -537,7 +566,7 @@ export class NavigationController
|
||||
}
|
||||
|
||||
public async setFavorite(tag: SNTag, favorite: boolean) {
|
||||
return this.application.mutator
|
||||
return this.application
|
||||
.changeAndSaveItem<TagMutator>(tag, (mutator) => {
|
||||
mutator.starred = favorite
|
||||
})
|
||||
@@ -545,7 +574,7 @@ export class NavigationController
|
||||
}
|
||||
|
||||
public setIcon(tag: SNTag, icon: VectorIconNameOrEmoji) {
|
||||
this.application.mutator
|
||||
this.application
|
||||
.changeAndSaveItem<TagMutator>(tag, (mutator) => {
|
||||
mutator.iconString = icon as string
|
||||
})
|
||||
@@ -570,7 +599,7 @@ export class NavigationController
|
||||
return
|
||||
}
|
||||
|
||||
const newTag = this.application.mutator.createTemplateItem(ContentType.Tag) as SNTag
|
||||
const newTag = this.application.items.createTemplateItem(ContentType.Tag) as SNTag
|
||||
|
||||
runInAction(() => {
|
||||
this.selectedLocation = 'all'
|
||||
@@ -593,7 +622,10 @@ export class NavigationController
|
||||
})
|
||||
}
|
||||
if (shouldDelete) {
|
||||
this.application.mutator.deleteItem(tag).catch(console.error)
|
||||
this.application.mutator
|
||||
.deleteItem(tag)
|
||||
.then(() => this.application.sync.sync())
|
||||
.catch(console.error)
|
||||
await this.setSelectedTag(this.smartViews[0], 'views')
|
||||
}
|
||||
}
|
||||
@@ -635,36 +667,18 @@ export class NavigationController
|
||||
}
|
||||
}
|
||||
|
||||
const insertedTag = await this.application.mutator.createTagOrSmartView(newTitle)
|
||||
const insertedTag = await this.application.mutator.createTagOrSmartView<SNTag>(
|
||||
newTitle,
|
||||
this.application.vaultDisplayService.exclusivelyShownVault,
|
||||
)
|
||||
this.application.sync.sync().catch(console.error)
|
||||
runInAction(() => {
|
||||
void this.setSelectedTag(insertedTag as SNTag, this.selectedLocation || 'views')
|
||||
void this.setSelectedTag(insertedTag, this.selectedLocation || 'views')
|
||||
})
|
||||
} else {
|
||||
await this.application.mutator.changeAndSaveItem<TagMutator>(tag, (mutator) => {
|
||||
await this.application.changeAndSaveItem<TagMutator>(tag, (mutator) => {
|
||||
mutator.title = newTitle
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TagsCountsState {
|
||||
public counts: { [uuid: string]: number } = {}
|
||||
|
||||
public constructor(private application: WebApplication) {
|
||||
makeAutoObservable(this, {
|
||||
counts: observable.ref,
|
||||
update: action,
|
||||
})
|
||||
}
|
||||
|
||||
public update(tags: SNTag[]) {
|
||||
const newCounts: { [uuid: string]: number } = Object.assign({}, this.counts)
|
||||
|
||||
tags.forEach((tag) => {
|
||||
newCounts[tag.uuid] = this.application.items.countableNotesForTag(tag)
|
||||
})
|
||||
|
||||
this.counts = newCounts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { SNTag } from '@standardnotes/snjs'
|
||||
import { action, makeAutoObservable, observable } from 'mobx'
|
||||
import { WebApplication } from '../../Application/WebApplication'
|
||||
|
||||
export class TagsCountsState {
|
||||
public counts: { [uuid: string]: number } = {}
|
||||
|
||||
public constructor(private application: WebApplication) {
|
||||
makeAutoObservable(this, {
|
||||
counts: observable.ref,
|
||||
update: action,
|
||||
})
|
||||
}
|
||||
|
||||
public update(tags: SNTag[]) {
|
||||
const newCounts: { [uuid: string]: number } = Object.assign({}, this.counts)
|
||||
|
||||
tags.forEach((tag) => {
|
||||
newCounts[tag.uuid] = this.application.items.countableNotesForTag(tag)
|
||||
})
|
||||
|
||||
this.counts = newCounts
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { storage, StorageKey } from '@standardnotes/ui-services'
|
||||
import { ApplicationEvent, InternalEventBus } from '@standardnotes/snjs'
|
||||
import { ApplicationEvent, InternalEventBusInterface } from '@standardnotes/snjs'
|
||||
import { runInAction, makeObservable, observable, action } from 'mobx'
|
||||
import { WebApplication } from '../Application/WebApplication'
|
||||
import { AbstractViewController } from './Abstract/AbstractViewController'
|
||||
@@ -7,7 +7,7 @@ import { AbstractViewController } from './Abstract/AbstractViewController'
|
||||
export class NoAccountWarningController extends AbstractViewController {
|
||||
show: boolean
|
||||
|
||||
constructor(application: WebApplication, eventBus: InternalEventBus) {
|
||||
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
|
||||
super(application, eventBus)
|
||||
|
||||
this.show = application.hasAccount() ? false : storage.get(StorageKey.ShowNoAccountWarning) ?? true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WebApplication } from '@/Application/WebApplication'
|
||||
import { InternalEventBus, SNNote } from '@standardnotes/snjs'
|
||||
import { InternalEventBusInterface, SNNote } from '@standardnotes/snjs'
|
||||
import { OPEN_NOTE_HISTORY_COMMAND } from '@standardnotes/ui-services'
|
||||
import { action, makeObservable, observable } from 'mobx'
|
||||
import { AbstractViewController } from '../Abstract/AbstractViewController'
|
||||
@@ -13,7 +13,11 @@ export class HistoryModalController extends AbstractViewController {
|
||||
this.note = undefined
|
||||
}
|
||||
|
||||
constructor(application: WebApplication, eventBus: InternalEventBus, notesController: NotesControllerInterface) {
|
||||
constructor(
|
||||
application: WebApplication,
|
||||
eventBus: InternalEventBusInterface,
|
||||
notesController: NotesControllerInterface,
|
||||
) {
|
||||
super(application, eventBus)
|
||||
|
||||
makeObservable(this, {
|
||||
|
||||
@@ -330,7 +330,7 @@ export class NoteHistoryController {
|
||||
}
|
||||
|
||||
if (didConfirm) {
|
||||
void this.application.mutator.changeAndSaveItem(
|
||||
void this.application.changeAndSaveItem(
|
||||
originalNote,
|
||||
(mutator) => {
|
||||
mutator.setCustomContent(revision.payload.content)
|
||||
@@ -344,11 +344,13 @@ export class NoteHistoryController {
|
||||
restoreRevisionAsCopy = async (revision: NonNullable<SelectedRevision>) => {
|
||||
const originalNote = this.application.items.findSureItem<SNNote>(revision.payload.uuid)
|
||||
|
||||
const duplicatedItem = await this.application.mutator.duplicateItem(originalNote, {
|
||||
const duplicatedItem = await this.application.mutator.duplicateItem(originalNote, false, {
|
||||
...revision.payload.content,
|
||||
title: revision.payload.content.title ? revision.payload.content.title + ' (copy)' : undefined,
|
||||
})
|
||||
|
||||
void this.application.sync.sync()
|
||||
|
||||
this.selectionController.selectItem(duplicatedItem.uuid).catch(console.error)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { WebApplication } from '@/Application/WebApplication'
|
||||
import { NoteMutator, SNNote } from '@standardnotes/models'
|
||||
import { MutationType, NoteMutator, SNNote } from '@standardnotes/models'
|
||||
import { InfoStrings } from '@standardnotes/snjs'
|
||||
import { Deferred } from '@standardnotes/utils'
|
||||
import { EditorSaveTimeoutDebounce } from '../Components/NoteView/Controller/EditorSaveTimeoutDebounce'
|
||||
@@ -106,7 +106,7 @@ export class NoteSyncController {
|
||||
noteMutator.preview_html = undefined
|
||||
}
|
||||
},
|
||||
params.isUserModified,
|
||||
params.isUserModified ? MutationType.UpdateUserTimestamps : MutationType.NoUpdateUserTimestamps,
|
||||
)
|
||||
|
||||
void this.application.sync.sync().then(() => {
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
NoteMutator,
|
||||
ContentType,
|
||||
SNTag,
|
||||
InternalEventBus,
|
||||
PrefKey,
|
||||
ApplicationEvent,
|
||||
EditorLineWidth,
|
||||
InternalEventBusInterface,
|
||||
MutationType,
|
||||
} from '@standardnotes/snjs'
|
||||
import { makeObservable, observable, action, computed, runInAction } from 'mobx'
|
||||
import { WebApplication } from '../../Application/WebApplication'
|
||||
@@ -48,7 +49,7 @@ export class NotesController extends AbstractViewController implements NotesCont
|
||||
application: WebApplication,
|
||||
private selectionController: SelectedItemsController,
|
||||
private navigationController: NavigationController,
|
||||
eventBus: InternalEventBus,
|
||||
eventBus: InternalEventBusInterface,
|
||||
) {
|
||||
super(application, eventBus)
|
||||
|
||||
@@ -201,7 +202,7 @@ export class NotesController extends AbstractViewController implements NotesCont
|
||||
}
|
||||
|
||||
async changeSelectedNotes(mutate: (mutator: NoteMutator) => void): Promise<void> {
|
||||
await this.application.mutator.changeItems(this.getSelectedNotesList(), mutate, false)
|
||||
await this.application.mutator.changeItems(this.getSelectedNotesList(), mutate, MutationType.NoUpdateUserTimestamps)
|
||||
this.application.sync.sync().catch(console.error)
|
||||
}
|
||||
|
||||
@@ -263,9 +264,8 @@ export class NotesController extends AbstractViewController implements NotesCont
|
||||
) {
|
||||
this.selectionController.selectNextItem()
|
||||
if (permanently) {
|
||||
for (const note of this.getSelectedNotesList()) {
|
||||
await this.application.mutator.deleteItem(note)
|
||||
}
|
||||
await this.application.mutator.deleteItems(this.getSelectedNotesList())
|
||||
void this.application.sync.sync()
|
||||
} else {
|
||||
await this.changeSelectedNotes((mutator) => {
|
||||
mutator.trashed = true
|
||||
@@ -332,12 +332,14 @@ export class NotesController extends AbstractViewController implements NotesCont
|
||||
async setProtectSelectedNotes(protect: boolean): Promise<void> {
|
||||
const selectedNotes = this.getSelectedNotesList()
|
||||
if (protect) {
|
||||
await this.application.mutator.protectNotes(selectedNotes)
|
||||
await this.application.protections.protectNotes(selectedNotes)
|
||||
this.setShowProtectedWarning(true)
|
||||
} else {
|
||||
await this.application.mutator.unprotectNotes(selectedNotes)
|
||||
await this.application.protections.unprotectNotes(selectedNotes)
|
||||
this.setShowProtectedWarning(false)
|
||||
}
|
||||
|
||||
void this.application.sync.sync()
|
||||
}
|
||||
|
||||
unselectNotes(): void {
|
||||
@@ -354,7 +356,7 @@ export class NotesController extends AbstractViewController implements NotesCont
|
||||
(mutator) => {
|
||||
mutator.toggleSpellcheck()
|
||||
},
|
||||
false,
|
||||
MutationType.NoUpdateUserTimestamps,
|
||||
)
|
||||
this.application.sync.sync().catch(console.error)
|
||||
}
|
||||
@@ -371,7 +373,7 @@ export class NotesController extends AbstractViewController implements NotesCont
|
||||
(mutator) => {
|
||||
mutator.editorWidth = editorWidth
|
||||
},
|
||||
false,
|
||||
MutationType.NoUpdateUserTimestamps,
|
||||
)
|
||||
this.application.sync.sync().catch(console.error)
|
||||
}
|
||||
@@ -380,7 +382,7 @@ export class NotesController extends AbstractViewController implements NotesCont
|
||||
const selectedNotes = this.getSelectedNotesList()
|
||||
await Promise.all(
|
||||
selectedNotes.map(async (note) => {
|
||||
await this.application.items.addTagToNote(note, tag, this.shouldLinkToParentFolders)
|
||||
await this.application.mutator.addTagToNote(note, tag, this.shouldLinkToParentFolders)
|
||||
}),
|
||||
)
|
||||
this.application.sync.sync().catch(console.error)
|
||||
@@ -414,7 +416,7 @@ export class NotesController extends AbstractViewController implements NotesCont
|
||||
confirmButtonStyle: 'danger',
|
||||
})
|
||||
) {
|
||||
this.application.mutator.emptyTrash().catch(console.error)
|
||||
await this.application.mutator.emptyTrash()
|
||||
this.application.sync.sync().catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
TOGGLE_LIST_PANE_KEYBOARD_COMMAND,
|
||||
TOGGLE_NAVIGATION_PANE_KEYBOARD_COMMAND,
|
||||
} from '@standardnotes/ui-services'
|
||||
import { ApplicationEvent, InternalEventBus, PrefKey, removeFromArray } from '@standardnotes/snjs'
|
||||
import { ApplicationEvent, InternalEventBusInterface, PrefKey, removeFromArray } from '@standardnotes/snjs'
|
||||
import { AppPaneId } from '../../Components/Panes/AppPaneMetadata'
|
||||
import { isMobileScreen } from '@/Utils'
|
||||
import { makeObservable, observable, action, computed } from 'mobx'
|
||||
@@ -35,7 +35,7 @@ export class PaneController extends AbstractViewController {
|
||||
listPaneExplicitelyCollapsed = false
|
||||
navigationPaneExplicitelyCollapsed = false
|
||||
|
||||
constructor(application: WebApplication, eventBus: InternalEventBus) {
|
||||
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
|
||||
super(application, eventBus)
|
||||
|
||||
makeObservable(this, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InternalEventBus } from '@standardnotes/snjs'
|
||||
import { InternalEventBusInterface } from '@standardnotes/snjs'
|
||||
import { action, computed, makeObservable, observable } from 'mobx'
|
||||
import { PreferenceId, RootQueryParam } from '@standardnotes/ui-services'
|
||||
import { AbstractViewController } from './Abstract/AbstractViewController'
|
||||
@@ -10,7 +10,7 @@ export class PreferencesController extends AbstractViewController {
|
||||
private _open = false
|
||||
currentPane: PreferenceId = DEFAULT_PANE
|
||||
|
||||
constructor(application: WebApplication, eventBus: InternalEventBus) {
|
||||
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
|
||||
super(application, eventBus)
|
||||
|
||||
makeObservable<PreferencesController, '_open'>(this, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LoggingDomain, log } from '@/Logging'
|
||||
import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowFunctions'
|
||||
import { InternalEventBus, AppleIAPProductId } from '@standardnotes/snjs'
|
||||
import { AppleIAPProductId, InternalEventBusInterface } from '@standardnotes/snjs'
|
||||
import { action, makeObservable, observable } from 'mobx'
|
||||
import { WebApplication } from '../../Application/WebApplication'
|
||||
import { AbstractViewController } from '../Abstract/AbstractViewController'
|
||||
@@ -10,7 +10,7 @@ export class PurchaseFlowController extends AbstractViewController {
|
||||
isOpen = false
|
||||
currentPane = PurchaseFlowPane.CreateAccount
|
||||
|
||||
constructor(application: WebApplication, eventBus: InternalEventBus) {
|
||||
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
|
||||
super(application, eventBus)
|
||||
|
||||
makeObservable(this, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { InternalEventBus } from '@standardnotes/snjs'
|
||||
import { InternalEventBusInterface } from '@standardnotes/snjs'
|
||||
import { WebApplication } from '@/Application/WebApplication'
|
||||
import { action, makeObservable, observable } from 'mobx'
|
||||
import { AbstractViewController } from './Abstract/AbstractViewController'
|
||||
@@ -7,7 +7,7 @@ export class QuickSettingsController extends AbstractViewController {
|
||||
open = false
|
||||
shouldAnimateCloseMenu = false
|
||||
|
||||
constructor(application: WebApplication, eventBus: InternalEventBus) {
|
||||
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
|
||||
super(application, eventBus)
|
||||
|
||||
makeObservable(this, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApplicationEvent, InternalEventBus } from '@standardnotes/snjs'
|
||||
import { ApplicationEvent, InternalEventBusInterface } from '@standardnotes/snjs'
|
||||
import { makeObservable, observable, action, runInAction } from 'mobx'
|
||||
import { WebApplication } from '../Application/WebApplication'
|
||||
import { AbstractViewController } from './Abstract/AbstractViewController'
|
||||
@@ -8,7 +8,7 @@ export class SearchOptionsController extends AbstractViewController {
|
||||
includeArchived = false
|
||||
includeTrashed = false
|
||||
|
||||
constructor(application: WebApplication, eventBus: InternalEventBus) {
|
||||
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
|
||||
super(application, eventBus)
|
||||
|
||||
makeObservable(this, {
|
||||
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
FileItem,
|
||||
SNNote,
|
||||
UuidString,
|
||||
InternalEventBus,
|
||||
isFile,
|
||||
Uuids,
|
||||
isNote,
|
||||
InternalEventBusInterface,
|
||||
} from '@standardnotes/snjs'
|
||||
import { SelectionControllerPersistableValue } from '@standardnotes/ui-services'
|
||||
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
|
||||
@@ -36,7 +36,7 @@ export class SelectedItemsController
|
||||
;(this.itemListController as unknown) = undefined
|
||||
}
|
||||
|
||||
constructor(application: WebApplication, eventBus: InternalEventBus) {
|
||||
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
|
||||
super(application, eventBus)
|
||||
|
||||
makeObservable(this, {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
ApplicationEvent,
|
||||
ClientDisplayableError,
|
||||
convertTimestampToMilliseconds,
|
||||
InternalEventBus,
|
||||
InternalEventBusInterface,
|
||||
Invitation,
|
||||
InvitationStatus,
|
||||
SubscriptionClientInterface,
|
||||
@@ -34,7 +34,7 @@ export class SubscriptionController extends AbstractViewController {
|
||||
|
||||
constructor(
|
||||
application: WebApplication,
|
||||
eventBus: InternalEventBus,
|
||||
eventBus: InternalEventBusInterface,
|
||||
private subscriptionManager: SubscriptionClientInterface,
|
||||
) {
|
||||
super(application, eventBus)
|
||||
@@ -188,7 +188,7 @@ export class SubscriptionController extends AbstractViewController {
|
||||
this.setAvailableSubscriptions(subscriptions)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
void error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { InternalEventBusInterface } from '@standardnotes/snjs'
|
||||
import { WebApplication } from '@/Application/WebApplication'
|
||||
import { action, makeObservable, observable } from 'mobx'
|
||||
import { AbstractViewController } from './Abstract/AbstractViewController'
|
||||
|
||||
export class VaultSelectionMenuController extends AbstractViewController {
|
||||
open = false
|
||||
shouldAnimateCloseMenu = false
|
||||
|
||||
constructor(application: WebApplication, eventBus: InternalEventBusInterface) {
|
||||
super(application, eventBus)
|
||||
|
||||
makeObservable(this, {
|
||||
open: observable,
|
||||
shouldAnimateCloseMenu: observable,
|
||||
|
||||
setOpen: action,
|
||||
setShouldAnimateCloseMenu: action,
|
||||
toggle: action,
|
||||
closeVaultSelectionMenu: action,
|
||||
})
|
||||
}
|
||||
|
||||
setOpen = (open: boolean): void => {
|
||||
this.open = open
|
||||
}
|
||||
|
||||
setShouldAnimateCloseMenu = (shouldAnimate: boolean): void => {
|
||||
this.shouldAnimateCloseMenu = shouldAnimate
|
||||
}
|
||||
|
||||
toggle = (): void => {
|
||||
if (this.open) {
|
||||
this.closeVaultSelectionMenu()
|
||||
} else {
|
||||
this.setOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
closeVaultSelectionMenu = (): void => {
|
||||
this.setShouldAnimateCloseMenu(true)
|
||||
setTimeout(() => {
|
||||
this.setOpen(false)
|
||||
this.setShouldAnimateCloseMenu(false)
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import { destroyAllObjectProperties } from '@/Utils'
|
||||
import {
|
||||
DeinitSource,
|
||||
WebOrDesktopDeviceInterface,
|
||||
InternalEventBus,
|
||||
SubscriptionClientInterface,
|
||||
InternalEventHandlerInterface,
|
||||
InternalEventInterface,
|
||||
@@ -41,6 +40,7 @@ import { CrossControllerEvent } from './CrossControllerEvent'
|
||||
import { EventObserverInterface } from '@/Event/EventObserverInterface'
|
||||
import { ApplicationEventObserver } from '@/Event/ApplicationEventObserver'
|
||||
import { ImportModalController } from './ImportModalController'
|
||||
import { VaultSelectionMenuController } from './VaultSelectionMenuController'
|
||||
|
||||
export class ViewControllerManager implements InternalEventHandlerInterface {
|
||||
readonly enableUnfinishedFeatures: boolean = window?.enabledUnfinishedFeatures
|
||||
@@ -60,6 +60,7 @@ export class ViewControllerManager implements InternalEventHandlerInterface {
|
||||
readonly preferencesController: PreferencesController
|
||||
readonly purchaseFlowController: PurchaseFlowController
|
||||
readonly quickSettingsMenuController: QuickSettingsController
|
||||
readonly vaultSelectionController: VaultSelectionMenuController
|
||||
readonly searchOptionsController: SearchOptionsController
|
||||
readonly subscriptionController: SubscriptionController
|
||||
readonly syncStatusController = new SyncStatusController()
|
||||
@@ -73,50 +74,52 @@ export class ViewControllerManager implements InternalEventHandlerInterface {
|
||||
public isSessionsModalVisible = false
|
||||
|
||||
private appEventObserverRemovers: (() => void)[] = []
|
||||
private eventBus: InternalEventBus
|
||||
|
||||
private subscriptionManager: SubscriptionClientInterface
|
||||
private persistenceService: PersistenceService
|
||||
private applicationEventObserver: EventObserverInterface
|
||||
private toastService: ToastServiceInterface
|
||||
|
||||
constructor(public application: WebApplication, private device: WebOrDesktopDeviceInterface) {
|
||||
this.eventBus = new InternalEventBus()
|
||||
const eventBus = application.internalEventBus
|
||||
|
||||
this.persistenceService = new PersistenceService(application, this.eventBus)
|
||||
this.persistenceService = new PersistenceService(application, eventBus)
|
||||
|
||||
this.eventBus.addEventHandler(this, CrossControllerEvent.HydrateFromPersistedValues)
|
||||
this.eventBus.addEventHandler(this, CrossControllerEvent.RequestValuePersistence)
|
||||
eventBus.addEventHandler(this, CrossControllerEvent.HydrateFromPersistedValues)
|
||||
eventBus.addEventHandler(this, CrossControllerEvent.RequestValuePersistence)
|
||||
|
||||
this.subscriptionManager = application.subscriptions
|
||||
|
||||
this.filePreviewModalController = new FilePreviewModalController(application)
|
||||
|
||||
this.quickSettingsMenuController = new QuickSettingsController(application, this.eventBus)
|
||||
this.quickSettingsMenuController = new QuickSettingsController(application, eventBus)
|
||||
|
||||
this.paneController = new PaneController(application, this.eventBus)
|
||||
this.vaultSelectionController = new VaultSelectionMenuController(application, eventBus)
|
||||
|
||||
this.preferencesController = new PreferencesController(application, this.eventBus)
|
||||
this.paneController = new PaneController(application, eventBus)
|
||||
|
||||
this.selectionController = new SelectedItemsController(application, this.eventBus)
|
||||
this.preferencesController = new PreferencesController(application, eventBus)
|
||||
|
||||
this.featuresController = new FeaturesController(application, this.eventBus)
|
||||
this.selectionController = new SelectedItemsController(application, eventBus)
|
||||
|
||||
this.navigationController = new NavigationController(application, this.featuresController, this.eventBus)
|
||||
this.featuresController = new FeaturesController(application, eventBus)
|
||||
|
||||
this.navigationController = new NavigationController(application, this.featuresController, eventBus)
|
||||
|
||||
this.notesController = new NotesController(
|
||||
application,
|
||||
this.selectionController,
|
||||
this.navigationController,
|
||||
this.eventBus,
|
||||
eventBus,
|
||||
)
|
||||
|
||||
this.searchOptionsController = new SearchOptionsController(application, this.eventBus)
|
||||
this.searchOptionsController = new SearchOptionsController(application, eventBus)
|
||||
|
||||
this.linkingController = new LinkingController(
|
||||
application,
|
||||
this.navigationController,
|
||||
this.selectionController,
|
||||
this.eventBus,
|
||||
eventBus,
|
||||
)
|
||||
|
||||
this.itemListController = new ItemListController(
|
||||
@@ -125,26 +128,25 @@ export class ViewControllerManager implements InternalEventHandlerInterface {
|
||||
this.searchOptionsController,
|
||||
this.selectionController,
|
||||
this.notesController,
|
||||
this.linkingController,
|
||||
this.eventBus,
|
||||
eventBus,
|
||||
)
|
||||
|
||||
this.notesController.setServicesPostConstruction(this.itemListController)
|
||||
this.selectionController.setServicesPostConstruction(this.itemListController)
|
||||
|
||||
this.noAccountWarningController = new NoAccountWarningController(application, this.eventBus)
|
||||
this.noAccountWarningController = new NoAccountWarningController(application, eventBus)
|
||||
|
||||
this.accountMenuController = new AccountMenuController(application, this.eventBus)
|
||||
this.accountMenuController = new AccountMenuController(application, eventBus)
|
||||
|
||||
this.subscriptionController = new SubscriptionController(application, this.eventBus, this.subscriptionManager)
|
||||
this.subscriptionController = new SubscriptionController(application, eventBus, this.subscriptionManager)
|
||||
|
||||
this.purchaseFlowController = new PurchaseFlowController(application, this.eventBus)
|
||||
this.purchaseFlowController = new PurchaseFlowController(application, eventBus)
|
||||
|
||||
this.filesController = new FilesController(
|
||||
application,
|
||||
this.notesController,
|
||||
this.filePreviewModalController,
|
||||
this.eventBus,
|
||||
eventBus,
|
||||
)
|
||||
|
||||
this.linkingController.setServicesPostConstruction(
|
||||
@@ -153,7 +155,7 @@ export class ViewControllerManager implements InternalEventHandlerInterface {
|
||||
this.subscriptionController,
|
||||
)
|
||||
|
||||
this.historyModalController = new HistoryModalController(this.application, this.eventBus, this.notesController)
|
||||
this.historyModalController = new HistoryModalController(this.application, eventBus, this.notesController)
|
||||
|
||||
this.importModalController = new ImportModalController(this.application, this.navigationController)
|
||||
|
||||
@@ -210,6 +212,7 @@ export class ViewControllerManager implements InternalEventHandlerInterface {
|
||||
;(this.filePreviewModalController as unknown) = undefined
|
||||
;(this.preferencesController as unknown) = undefined
|
||||
;(this.quickSettingsMenuController as unknown) = undefined
|
||||
;(this.vaultSelectionController as unknown) = undefined
|
||||
;(this.syncStatusController as unknown) = undefined
|
||||
|
||||
this.persistenceService.deinit()
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { isDev } from '@/Utils'
|
||||
import { InternalFeature, InternalFeatureService } from '@standardnotes/snjs'
|
||||
|
||||
export enum FeatureTrunkName {
|
||||
Super,
|
||||
ImportTools,
|
||||
}
|
||||
export enum FeatureTrunkName {}
|
||||
|
||||
const FeatureTrunkStatus: Record<FeatureTrunkName, boolean> = {
|
||||
[FeatureTrunkName.Super]: isDev && true,
|
||||
[FeatureTrunkName.ImportTools]: isDev && true,
|
||||
}
|
||||
const FeatureTrunkStatus: Record<FeatureTrunkName, boolean> = {}
|
||||
|
||||
export function featureTrunkEnabled(trunk: FeatureTrunkName): boolean {
|
||||
return FeatureTrunkStatus[trunk]
|
||||
}
|
||||
|
||||
export function featureTrunkVaultsEnabled(): boolean {
|
||||
return InternalFeatureService.get().isFeatureEnabled(InternalFeature.Vaults)
|
||||
}
|
||||
|
||||
29
packages/web/src/javascripts/Hooks/useItem.ts
Normal file
29
packages/web/src/javascripts/Hooks/useItem.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useApplication } from '@/Components/ApplicationProvider'
|
||||
import { DecryptedItemInterface, LiveItem } from '@standardnotes/snjs'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const useItem = <T extends DecryptedItemInterface>(uuid: string | undefined) => {
|
||||
const application = useApplication()
|
||||
|
||||
const [item, setItem] = useState<T>()
|
||||
|
||||
useEffect(() => {
|
||||
if (!uuid) {
|
||||
return
|
||||
}
|
||||
|
||||
const live = new LiveItem<T>(uuid, application, (item) => {
|
||||
setItem(item)
|
||||
})
|
||||
|
||||
return () => live.deinit()
|
||||
}, [uuid, application])
|
||||
|
||||
if (!uuid) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
export default useItem
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactNativeToWebEvent, WebApplicationInterface } from '@standardnotes/snjs'
|
||||
import { ReactNativeToWebEvent } from '@standardnotes/snjs'
|
||||
import { WebApplicationInterface } from '@standardnotes/ui-services'
|
||||
|
||||
export type NativeMobileEventListener = (event: ReactNativeToWebEvent) => void
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Valid only when running a mock event publisher on port 3124 */
|
||||
export async function purchaseMockSubscription(email: string, subscriptionId: number) {
|
||||
const response = await fetch('http://localhost:3124/events', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
eventType: 'SUBSCRIPTION_PURCHASED',
|
||||
eventPayload: {
|
||||
userEmail: email,
|
||||
subscriptionId: subscriptionId,
|
||||
subscriptionName: 'PRO_PLAN',
|
||||
subscriptionExpiresAt: (new Date().getTime() + 3_600_000) * 1_000,
|
||||
timestamp: Date.now(),
|
||||
offline: false,
|
||||
discountCode: null,
|
||||
limitedDiscountPurchased: false,
|
||||
newSubscriber: true,
|
||||
totalActiveSubscriptionsCount: 1,
|
||||
userRegisteredAt: 1,
|
||||
billingFrequency: 12,
|
||||
payAmount: 59.0,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to publish mocked event: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DecryptedItem, SNTag, WebApplicationInterface } from '@standardnotes/snjs'
|
||||
import { DecryptedItem, SNTag } from '@standardnotes/snjs'
|
||||
import { WebApplicationInterface } from '@standardnotes/ui-services'
|
||||
|
||||
type ReturnType =
|
||||
| {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { IconType, FileItem, SNNote, DecryptedItem, SNTag, WebApplicationInterface } from '@standardnotes/snjs'
|
||||
import { IconType, FileItem, SNNote, DecryptedItem, SNTag } from '@standardnotes/snjs'
|
||||
import { getIconAndTintForNoteType } from './getIconAndTintForNoteType'
|
||||
import { getIconForFileType } from './getIconForFileType'
|
||||
import { WebApplicationInterface } from '@standardnotes/ui-services'
|
||||
|
||||
export function getIconForItem(item: DecryptedItem, application: WebApplicationInterface): [IconType, string] {
|
||||
if (item instanceof SNNote) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user