= ({
onClick={onClick}
onContextMenu={onContextMenu}
>
- {flags && flags.length > 0 ? (
-
- {flags.map((flag) => (
-
- ))}
-
- ) : null}
-
{note.title}
- {!hidePreview && !note.hidePreview && !note.protected ? (
-
- {note.preview_html ? (
-
- ) : null}
- {!note.preview_html && note.preview_plain ? (
-
{note.preview_plain}
- ) : null}
- {!note.preview_html && !note.preview_plain ? (
-
{note.text}
- ) : null}
-
- ) : null}
- {!hideDate || note.protected ? (
-
- {note.protected ? (
- Protected {hideDate ? '' : ' • '}
- ) : null}
- {!hideDate && showModifiedDate ? (
- Modified {note.updatedAtString || 'Now'}
- ) : null}
- {!hideDate && !showModifiedDate ? (
- {note.createdAtString || 'Now'}
- ) : null}
-
- ) : null}
- {!hideTags && (
-
-
{tags}
+ {!hideEditorIcon && (
+
+
)}
+
+
+
{note.title}
+
+ {note.locked && (
+
+
+
+ )}
+ {note.trashed && (
+
+
+
+ )}
+ {note.archived && (
+
+
+
+ )}
+ {note.pinned && (
+
+
+
+ )}
+
+
+ {!hidePreview && !note.hidePreview && !note.protected && (
+
+ {note.preview_html && (
+
+ )}
+ {!note.preview_html && note.preview_plain && (
+
{note.preview_plain}
+ )}
+ {!note.preview_html && !note.preview_plain && note.text && (
+
{note.text}
+ )}
+
+ )}
+ {!hideDate || note.protected ? (
+
+ {note.protected && Protected {hideDate ? '' : ' • '}}
+ {!hideDate && showModifiedDate && (
+ Modified {note.updatedAtString || 'Now'}
+ )}
+ {!hideDate && !showModifiedDate && (
+ {note.createdAtString || 'Now'}
+ )}
+
+ ) : null}
+ {!hideTags && tags.length ? (
+
+ {tags.map((tag) => (
+
+
+ {tag}
+
+ ))}
+
+ ) : null}
+ {flags.length ? (
+
+ {flags.map((flag) => (
+
+ ))}
+
+ ) : null}
+
);
};
diff --git a/app/assets/javascripts/components/NotesListOptionsMenu.tsx b/app/assets/javascripts/components/NotesListOptionsMenu.tsx
index ea59f76f0..501ca4cf8 100644
--- a/app/assets/javascripts/components/NotesListOptionsMenu.tsx
+++ b/app/assets/javascripts/components/NotesListOptionsMenu.tsx
@@ -46,6 +46,9 @@ flex flex-col py-2 bottom-0 left-2 absolute';
const [hideProtected, setHideProtected] = useState(() =>
application.getPreference(PrefKey.NotesHideProtected, false)
);
+ const [hideEditorIcon, setHideEditorIcon] = useState(() =>
+ application.getPreference(PrefKey.NotesHideEditorIcon, false)
+ );
const toggleSortReverse = () => {
application.setPreference(PrefKey.SortNotesReverse, !sortReverse);
@@ -108,9 +111,14 @@ flex flex-col py-2 bottom-0 left-2 absolute';
application.setPreference(PrefKey.NotesHideProtected, !hideProtected);
};
+ const toggleEditorIcon = () => {
+ setHideEditorIcon(!hideEditorIcon);
+ application.setPreference(PrefKey.NotesHideEditorIcon, !hideEditorIcon);
+ };
+
const menuRef = useRef
(null);
- useCloseOnClickOutside(menuRef as any, (open: boolean) => {
+ useCloseOnClickOutside(menuRef, (open: boolean) => {
if (!open) {
closeDisplayOptionsMenu();
}
@@ -201,6 +209,14 @@ flex flex-col py-2 bottom-0 left-2 absolute';
>
Show tags
+
Other
diff --git a/app/assets/javascripts/components/NotesView.tsx b/app/assets/javascripts/components/NotesView.tsx
index 004a54e1d..622bd31aa 100644
--- a/app/assets/javascripts/components/NotesView.tsx
+++ b/app/assets/javascripts/components/NotesView.tsx
@@ -124,9 +124,9 @@ const NotesView: FunctionComponent
= observer(
};
const panelResizeFinishCallback: ResizeFinishCallback = (
- _w,
- _l,
- _mw,
+ _lastWidth,
+ _lastLeft,
+ _isMaxWidth,
isCollapsed
) => {
appState.noteTags.reloadTagsContainerMaxWidth();
@@ -230,6 +230,7 @@ const NotesView: FunctionComponent = observer(
{
return value;
};
-export const PremiumModalProvider: FunctionalComponent = ({ children }) => {
- const [featureName, setFeatureName] = useState(null);
+interface Props {
+ state: FeaturesState;
+}
- const activate = setFeatureName;
+export const PremiumModalProvider: FunctionalComponent = observer(
+ ({ state, children }) => {
+ const featureName = state._premiumAlertFeatureName;
+ const activate = state.showPremiumAlert;
+ const close = state.closePremiumAlert;
- const closeModal = useCallback(() => {
- setFeatureName(null);
- }, [setFeatureName]);
+ const showModal = !!featureName;
- const showModal = !!featureName;
-
- return (
- <>
- {showModal && (
-
- )}
-
- {children}
-
- >
- );
-};
+ return (
+ <>
+ {showModal && (
+
+ )}
+
+ {children}
+
+ >
+ );
+ }
+);
diff --git a/app/assets/javascripts/components/RootTagDropZone.tsx b/app/assets/javascripts/components/Tags/RootTagDropZone.tsx
similarity index 86%
rename from app/assets/javascripts/components/RootTagDropZone.tsx
rename to app/assets/javascripts/components/Tags/RootTagDropZone.tsx
index 0e39ef5a8..3e758536c 100644
--- a/app/assets/javascripts/components/RootTagDropZone.tsx
+++ b/app/assets/javascripts/components/Tags/RootTagDropZone.tsx
@@ -1,3 +1,5 @@
+import { Icon } from '@/components/Icon';
+import { usePremiumModal } from '@/components/Premium';
import {
FeaturesState,
TAG_FOLDERS_FEATURE_NAME,
@@ -5,9 +7,7 @@ import {
import { TagsState } from '@/ui_models/app_state/tags_state';
import { observer } from 'mobx-react-lite';
import { useDrop } from 'react-dnd';
-import { Icon } from './Icon';
-import { usePremiumModal } from './Premium';
-import { DropItem, DropProps, ItemTypes } from './TagsListItem';
+import { DropItem, DropProps, ItemTypes } from './dragndrop';
type Props = {
tagsState: TagsState;
@@ -18,7 +18,7 @@ export const RootTagDropZone: React.FC = observer(
({ tagsState, featuresState }) => {
const premiumModal = usePremiumModal();
const isNativeFoldersEnabled = featuresState.enableNativeFoldersFeature;
- const hasFolders = tagsState.hasFolders;
+ const hasFolders = featuresState.hasFolders;
const [{ isOver, canDrop }, dropRef] = useDrop(
() => ({
@@ -50,7 +50,7 @@ export const RootTagDropZone: React.FC = observer(
diff --git a/app/assets/javascripts/components/Tags/SmartTagsList.tsx b/app/assets/javascripts/components/Tags/SmartTagsList.tsx
new file mode 100644
index 000000000..77b06eec8
--- /dev/null
+++ b/app/assets/javascripts/components/Tags/SmartTagsList.tsx
@@ -0,0 +1,29 @@
+import { AppState } from '@/ui_models/app_state';
+import { observer } from 'mobx-react-lite';
+import { FunctionComponent } from 'preact';
+import { SmartTagsListItem } from './SmartTagsListItem';
+
+type Props = {
+ appState: AppState;
+};
+
+export const SmartTagsList: FunctionComponent
= observer(
+ ({ appState }) => {
+ const allTags = appState.tags.smartTags;
+
+ return (
+ <>
+ {allTags.map((tag) => {
+ return (
+
+ );
+ })}
+ >
+ );
+ }
+);
diff --git a/app/assets/javascripts/components/Tags/SmartTagsListItem.tsx b/app/assets/javascripts/components/Tags/SmartTagsListItem.tsx
new file mode 100644
index 000000000..c82b79878
--- /dev/null
+++ b/app/assets/javascripts/components/Tags/SmartTagsListItem.tsx
@@ -0,0 +1,168 @@
+import { Icon, IconType } from '@/components/Icon';
+import { FeaturesState } from '@/ui_models/app_state/features_state';
+import { TagsState } from '@/ui_models/app_state/tags_state';
+import '@reach/tooltip/styles.css';
+import { SNSmartTag } from '@standardnotes/snjs';
+import { observer } from 'mobx-react-lite';
+import { FunctionComponent } from 'preact';
+import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
+
+type Props = {
+ tag: SNSmartTag;
+ tagsState: TagsState;
+ features: FeaturesState;
+};
+
+const PADDING_BASE_PX = 14;
+const PADDING_PER_LEVEL_PX = 21;
+
+const smartTagIconType = (tag: SNSmartTag): IconType => {
+ if (tag.isAllTag) {
+ return 'notes';
+ }
+ if (tag.isArchiveTag) {
+ return 'archive';
+ }
+ if (tag.isTrashTag) {
+ return 'trash';
+ }
+ return 'hashtag';
+};
+
+export const SmartTagsListItem: FunctionComponent = observer(
+ ({ tag, tagsState, features }) => {
+ const [title, setTitle] = useState(tag.title || '');
+ const inputRef = useRef(null);
+
+ const level = 0;
+ const isSelected = tagsState.selected === tag;
+ const isEditing = tagsState.editingTag === tag;
+ const isSmartTagsEnabled = features.enableNativeSmartTagsFeature;
+
+ useEffect(() => {
+ setTitle(tag.title || '');
+ }, [setTitle, tag]);
+
+ const selectCurrentTag = useCallback(() => {
+ tagsState.selected = tag;
+ }, [tagsState, tag]);
+
+ const onBlur = useCallback(() => {
+ tagsState.save(tag, title);
+ setTitle(tag.title);
+ }, [tagsState, tag, title, setTitle]);
+
+ const onInput = useCallback(
+ (e: Event) => {
+ const value = (e.target as HTMLInputElement).value;
+ setTitle(value);
+ },
+ [setTitle]
+ );
+
+ const onKeyUp = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.code === 'Enter') {
+ inputRef.current?.blur();
+ e.preventDefault();
+ }
+ },
+ [inputRef]
+ );
+
+ useEffect(() => {
+ if (isEditing) {
+ inputRef.current?.focus();
+ }
+ }, [inputRef, isEditing]);
+
+ const onClickRename = useCallback(() => {
+ tagsState.editingTag = tag;
+ }, [tagsState, tag]);
+
+ const onClickSave = useCallback(() => {
+ inputRef.current?.blur();
+ }, [inputRef]);
+
+ const onClickDelete = useCallback(() => {
+ tagsState.remove(tag);
+ }, [tagsState, tag]);
+
+ const isFaded = !isSmartTagsEnabled && !tag.isAllTag;
+ const iconType = smartTagIconType(tag);
+
+ return (
+ <>
+
+ {!tag.errorDecrypting ? (
+
+ {isSmartTagsEnabled && (
+
+
+
+ )}
+
+
+ {tag.isAllTag && tagsState.allNotesCount}
+
+
+ ) : null}
+ {!tag.isSystemSmartTag && (
+
+ {tag.conflictOf && (
+
+ Conflicted Copy {tag.conflictOf}
+
+ )}
+ {tag.errorDecrypting && !tag.waitingForKey && (
+
Missing Keys
+ )}
+ {tag.errorDecrypting && tag.waitingForKey && (
+
+ Waiting For Keys
+
+ )}
+ {isSelected && (
+
+ )}
+
+ )}
+
+ >
+ );
+ }
+);
diff --git a/app/assets/javascripts/components/Tags/SmartTagsSection.tsx b/app/assets/javascripts/components/Tags/SmartTagsSection.tsx
new file mode 100644
index 000000000..2032aa6ce
--- /dev/null
+++ b/app/assets/javascripts/components/Tags/SmartTagsSection.tsx
@@ -0,0 +1,18 @@
+import { AppState } from '@/ui_models/app_state';
+import { observer } from 'mobx-react-lite';
+import { FunctionComponent } from 'preact';
+import { SmartTagsList } from './SmartTagsList';
+
+type Props = {
+ appState: AppState;
+};
+
+export const SmartTagsSection: FunctionComponent = observer(
+ ({ appState }) => {
+ return (
+
+ );
+ }
+);
diff --git a/app/assets/javascripts/components/Tags/TagsList.tsx b/app/assets/javascripts/components/Tags/TagsList.tsx
new file mode 100644
index 000000000..0d6ab0b15
--- /dev/null
+++ b/app/assets/javascripts/components/Tags/TagsList.tsx
@@ -0,0 +1,48 @@
+import { AppState } from '@/ui_models/app_state';
+import { isMobile } from '@/utils';
+import { observer } from 'mobx-react-lite';
+import { FunctionComponent } from 'preact';
+import { DndProvider } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';
+import { TouchBackend } from 'react-dnd-touch-backend';
+import { RootTagDropZone } from './RootTagDropZone';
+import { TagsListItem } from './TagsListItem';
+
+type Props = {
+ appState: AppState;
+};
+
+export const TagsList: FunctionComponent = observer(({ appState }) => {
+ const tagsState = appState.tags;
+ const allTags = tagsState.allLocalRootTags;
+
+ const backend = isMobile({ tablet: true }) ? TouchBackend : HTML5Backend;
+
+ return (
+
+ {allTags.length === 0 ? (
+
+ No tags. Create one using the add button above.
+
+ ) : (
+ <>
+ {allTags.map((tag) => {
+ return (
+
+ );
+ })}
+
+ >
+ )}
+
+ );
+});
diff --git a/app/assets/javascripts/components/TagsListItem.tsx b/app/assets/javascripts/components/Tags/TagsListItem.tsx
similarity index 81%
rename from app/assets/javascripts/components/TagsListItem.tsx
rename to app/assets/javascripts/components/Tags/TagsListItem.tsx
index ed6e8cf5e..ed16e34a0 100644
--- a/app/assets/javascripts/components/TagsListItem.tsx
+++ b/app/assets/javascripts/components/Tags/TagsListItem.tsx
@@ -1,3 +1,5 @@
+import { Icon } from '@/components/Icon';
+import { usePremiumModal } from '@/components/Premium';
import {
FeaturesState,
TAG_FOLDERS_FEATURE_NAME,
@@ -5,56 +7,39 @@ import {
import { TagsState } from '@/ui_models/app_state/tags_state';
import '@reach/tooltip/styles.css';
import { SNTag } from '@standardnotes/snjs';
-import { computed, runInAction } from 'mobx';
+import { computed } from 'mobx';
import { observer } from 'mobx-react-lite';
import { FunctionComponent, JSX } from 'preact';
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { useDrag, useDrop } from 'react-dnd';
-import { Icon } from './Icon';
-import { usePremiumModal } from './Premium';
-
-export enum ItemTypes {
- TAG = 'TAG',
-}
-
-export type DropItemTag = { uuid: string };
-
-export type DropItem = DropItemTag;
-
-export type DropProps = { isOver: boolean; canDrop: boolean };
+import { DropItem, DropProps, ItemTypes } from './dragndrop';
type Props = {
tag: SNTag;
tagsState: TagsState;
- selectTag: (tag: SNTag) => void;
- removeTag: (tag: SNTag) => void;
- saveTag: (tag: SNTag, newTitle: string) => void;
- appState: TagsListState;
+ features: FeaturesState;
level: number;
};
-export type TagsListState = {
- readonly selectedTag: SNTag | undefined;
- tags: TagsState;
- editingTag: SNTag | undefined;
- features: FeaturesState;
-};
+const PADDING_BASE_PX = 14;
+const PADDING_PER_LEVEL_PX = 21;
export const TagsListItem: FunctionComponent = observer(
- ({ tag, selectTag, saveTag, removeTag, appState, tagsState, level }) => {
+ ({ tag, features, tagsState, level }) => {
const [title, setTitle] = useState(tag.title || '');
const inputRef = useRef(null);
- const isSelected = appState.selectedTag === tag;
- const isEditing = appState.editingTag === tag;
- const noteCounts = computed(() => appState.tags.getNotesCount(tag));
+ const isSelected = tagsState.selected === tag;
+ const isEditing = tagsState.editingTag === tag;
+ const noteCounts = computed(() => tagsState.getNotesCount(tag));
const childrenTags = computed(() => tagsState.getChildren(tag)).get();
const hasChildren = childrenTags.length > 0;
- const hasFolders = tagsState.hasFolders;
- const isNativeFoldersEnabled = appState.features.enableNativeFoldersFeature;
+ const hasFolders = features.hasFolders;
+ const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
const hasAtLeastOneFolder = tagsState.hasAtLeastOneFolder;
+
const premiumModal = usePremiumModal();
const [showChildren, setShowChildren] = useState(hasChildren);
@@ -80,16 +65,13 @@ export const TagsListItem: FunctionComponent = observer(
);
const selectCurrentTag = useCallback(() => {
- if (isEditing || isSelected) {
- return;
- }
- selectTag(tag);
- }, [isSelected, isEditing, selectTag, tag]);
+ tagsState.selected = tag;
+ }, [tagsState, tag]);
const onBlur = useCallback(() => {
- saveTag(tag, title);
+ tagsState.save(tag, title);
setTitle(tag.title);
- }, [tag, saveTag, title, setTitle]);
+ }, [tagsState, tag, title, setTitle]);
const onInput = useCallback(
(e: JSX.TargetedEvent) => {
@@ -116,18 +98,16 @@ export const TagsListItem: FunctionComponent = observer(
}, [inputRef, isEditing]);
const onClickRename = useCallback(() => {
- runInAction(() => {
- appState.editingTag = tag;
- });
- }, [appState, tag]);
+ tagsState.editingTag = tag;
+ }, [tagsState, tag]);
const onClickSave = useCallback(() => {
inputRef.current?.blur();
}, [inputRef]);
const onClickDelete = useCallback(() => {
- removeTag(tag);
- }, [removeTag, tag]);
+ tagsState.remove(tag);
+ }, [tagsState, tag]);
const [, dragRef] = useDrag(
() => ({
@@ -174,7 +154,9 @@ export const TagsListItem: FunctionComponent = observer(
}`}
onClick={selectCurrentTag}
ref={dragRef}
- style={{ paddingLeft: `${level * 21 + 10}px` }}
+ style={{
+ paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
+ }}
>
{!tag.errorDecrypting ? (
@@ -255,10 +237,7 @@ export const TagsListItem: FunctionComponent
= observer(
key={tag.uuid}
tag={tag}
tagsState={tagsState}
- selectTag={selectTag}
- saveTag={saveTag}
- removeTag={removeTag}
- appState={appState}
+ features={features}
/>
);
})}
diff --git a/app/assets/javascripts/components/Tags/TagsSection.tsx b/app/assets/javascripts/components/Tags/TagsSection.tsx
index 5cc7a452d..38d97b9e0 100644
--- a/app/assets/javascripts/components/Tags/TagsSection.tsx
+++ b/app/assets/javascripts/components/Tags/TagsSection.tsx
@@ -1,108 +1,29 @@
-import { TagsList } from '@/components/TagsList';
-import { toDirective } from '@/components/utils';
-import { WebApplication } from '@/ui_models/application';
+import { TagsList } from '@/components/Tags/TagsList';
import { AppState } from '@/ui_models/app_state';
-import {
- FeaturesState,
- TAG_FOLDERS_FEATURE_NAME,
- TAG_FOLDERS_FEATURE_TOOLTIP,
-} from '@/ui_models/app_state/features_state';
-import { Tooltip } from '@reach/tooltip';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
-import { useCallback } from 'preact/hooks';
-import { IconButton } from '../IconButton';
-import { PremiumModalProvider, usePremiumModal } from '../Premium';
+import { TagsSectionAddButton } from './TagsSectionAddButton';
+import { TagsSectionTitle } from './TagsSectionTitle';
type Props = {
- application: WebApplication;
appState: AppState;
};
-const TagAddButton: FunctionComponent<{
- appState: AppState;
- features: FeaturesState;
-}> = observer(({ appState, features }) => {
- const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
-
- if (!isNativeFoldersEnabled) {
- return null;
- }
-
- return (
- appState.createNewTag()}
- />
- );
-});
-
-const TagTitle: FunctionComponent<{
- features: FeaturesState;
-}> = observer(({ features }) => {
- const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
- const hasFolders = features.hasFolders;
- const modal = usePremiumModal();
-
- const showPremiumAlert = useCallback(() => {
- modal.activate(TAG_FOLDERS_FEATURE_NAME);
- }, [modal]);
-
- if (!isNativeFoldersEnabled) {
- return (
- <>
-
- Tags
-
- >
- );
- }
-
- if (hasFolders) {
- return (
- <>
-
- Folders
-
- >
- );
- }
-
- return (
- <>
-
- Tags
-
-
-
-
- >
- );
-});
-
export const TagsSection: FunctionComponent = observer(
- ({ application, appState }) => {
+ ({ appState }) => {
return (
-
-
-
-
-
-
-
+
);
}
);
-
-export const TagsSectionDirective = toDirective
(TagsSection);
diff --git a/app/assets/javascripts/components/Tags/TagsSectionAddButton.tsx b/app/assets/javascripts/components/Tags/TagsSectionAddButton.tsx
new file mode 100644
index 000000000..990bbc762
--- /dev/null
+++ b/app/assets/javascripts/components/Tags/TagsSectionAddButton.tsx
@@ -0,0 +1,30 @@
+import { IconButton } from '@/components/IconButton';
+import { FeaturesState } from '@/ui_models/app_state/features_state';
+import { TagsState } from '@/ui_models/app_state/tags_state';
+import { observer } from 'mobx-react-lite';
+import { FunctionComponent } from 'preact';
+
+type Props = {
+ tags: TagsState;
+ features: FeaturesState;
+};
+
+export const TagsSectionAddButton: FunctionComponent = observer(
+ ({ tags, features }) => {
+ const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
+
+ if (!isNativeFoldersEnabled) {
+ return null;
+ }
+
+ return (
+ tags.createNewTemplate()}
+ />
+ );
+ }
+);
diff --git a/app/assets/javascripts/components/Tags/TagsSectionTitle.tsx b/app/assets/javascripts/components/Tags/TagsSectionTitle.tsx
new file mode 100644
index 000000000..6298dbf1e
--- /dev/null
+++ b/app/assets/javascripts/components/Tags/TagsSectionTitle.tsx
@@ -0,0 +1,62 @@
+import { usePremiumModal } from '@/components/Premium';
+import {
+ FeaturesState,
+ TAG_FOLDERS_FEATURE_NAME,
+ TAG_FOLDERS_FEATURE_TOOLTIP,
+} from '@/ui_models/app_state/features_state';
+import { Tooltip } from '@reach/tooltip';
+import { observer } from 'mobx-react-lite';
+import { FunctionComponent } from 'preact';
+import { useCallback } from 'preact/hooks';
+
+type Props = {
+ features: FeaturesState;
+};
+
+export const TagsSectionTitle: FunctionComponent = observer(
+ ({ features }) => {
+ const isNativeFoldersEnabled = features.enableNativeFoldersFeature;
+ const hasFolders = features.hasFolders;
+ const modal = usePremiumModal();
+
+ const showPremiumAlert = useCallback(() => {
+ modal.activate(TAG_FOLDERS_FEATURE_NAME);
+ }, [modal]);
+
+ if (!isNativeFoldersEnabled) {
+ return (
+ <>
+
+ Tags
+
+ >
+ );
+ }
+
+ if (hasFolders) {
+ return (
+ <>
+
+ Folders
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+ Tags
+
+
+
+
+ >
+ );
+ }
+);
diff --git a/app/assets/javascripts/components/Tags/dragndrop.ts b/app/assets/javascripts/components/Tags/dragndrop.ts
new file mode 100644
index 000000000..5a5033ad5
--- /dev/null
+++ b/app/assets/javascripts/components/Tags/dragndrop.ts
@@ -0,0 +1,9 @@
+export enum ItemTypes {
+ TAG = 'TAG',
+}
+
+export type DropItemTag = { uuid: string };
+
+export type DropItem = DropItemTag;
+
+export type DropProps = { isOver: boolean; canDrop: boolean };
diff --git a/app/assets/javascripts/components/TagsList.tsx b/app/assets/javascripts/components/TagsList.tsx
deleted file mode 100644
index 66925c2c1..000000000
--- a/app/assets/javascripts/components/TagsList.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import { PremiumModalProvider } from '@/components/Premium';
-import { confirmDialog } from '@/services/alertService';
-import { STRING_DELETE_TAG } from '@/strings';
-import { WebApplication } from '@/ui_models/application';
-import { AppState } from '@/ui_models/app_state';
-import { isMobile } from '@/utils';
-import { SNTag, TagMutator } from '@standardnotes/snjs';
-import { runInAction } from 'mobx';
-import { observer } from 'mobx-react-lite';
-import { FunctionComponent } from 'preact';
-import { useCallback } from 'preact/hooks';
-import { DndProvider } from 'react-dnd';
-import { HTML5Backend } from 'react-dnd-html5-backend';
-import { TouchBackend } from 'react-dnd-touch-backend';
-import { RootTagDropZone } from './RootTagDropZone';
-import { TagsListItem } from './TagsListItem';
-import { toDirective } from './utils';
-
-type Props = {
- application: WebApplication;
- appState: AppState;
-};
-
-const tagsWithOptionalTemplate = (
- template: SNTag | undefined,
- tags: SNTag[]
-): SNTag[] => {
- if (!template) {
- return tags;
- }
- return [template, ...tags];
-};
-
-export const TagsList: FunctionComponent = observer(
- ({ application, appState }) => {
- const templateTag = appState.templateTag;
- const rootTags = appState.tags.rootTags;
-
- const allTags = tagsWithOptionalTemplate(templateTag, rootTags);
-
- const selectTag = useCallback(
- (tag: SNTag) => {
- appState.setSelectedTag(tag);
- },
- [appState]
- );
-
- const saveTag = useCallback(
- async (tag: SNTag, newTitle: string) => {
- const templateTag = appState.templateTag;
-
- const hasEmptyTitle = newTitle.length === 0;
- const hasNotChangedTitle = newTitle === tag.title;
- const isTemplateChange = templateTag && tag.uuid === templateTag.uuid;
- const hasDuplicatedTitle = !!application.findTagByTitle(newTitle);
-
- runInAction(() => {
- appState.templateTag = undefined;
- appState.editingTag = undefined;
- });
-
- if (hasEmptyTitle || hasNotChangedTitle) {
- if (isTemplateChange) {
- appState.undoCreateNewTag();
- }
- return;
- }
-
- if (hasDuplicatedTitle) {
- if (isTemplateChange) {
- appState.undoCreateNewTag();
- }
- application.alertService?.alert(
- 'A tag with this name already exists.'
- );
- return;
- }
-
- if (isTemplateChange) {
- const insertedTag = await application.insertItem(templateTag);
- const changedTag = await application.changeItem(
- insertedTag.uuid,
- (m) => {
- m.title = newTitle;
- }
- );
-
- selectTag(changedTag as SNTag);
- await application.saveItem(insertedTag.uuid);
- } else {
- await application.changeAndSaveItem(
- tag.uuid,
- (mutator) => {
- mutator.title = newTitle;
- }
- );
- }
- },
- [appState, application, selectTag]
- );
-
- const removeTag = useCallback(
- async (tag: SNTag) => {
- if (
- await confirmDialog({
- text: STRING_DELETE_TAG,
- confirmButtonStyle: 'danger',
- })
- ) {
- appState.removeTag(tag);
- }
- },
- [appState]
- );
-
- const backend = isMobile({ tablet: true }) ? TouchBackend : HTML5Backend;
-
- return (
-
-
- {allTags.length === 0 ? (
-
- No tags. Create one using the add button above.
-
- ) : (
- <>
- {allTags.map((tag) => {
- return (
-
- );
- })}
-
- >
- )}
-
-
- );
- }
-);
-
-export const TagsListDirective = toDirective(TagsList);
diff --git a/app/assets/javascripts/components/utils.ts b/app/assets/javascripts/components/utils.ts
index 3e4f9b631..5c0d1adb4 100644
--- a/app/assets/javascripts/components/utils.ts
+++ b/app/assets/javascripts/components/utils.ts
@@ -32,7 +32,7 @@ export function useCloseOnBlur(
}
export function useCloseOnClickOutside(
- container: { current: HTMLDivElement },
+ container: { current: HTMLDivElement | null },
setOpen: (open: boolean) => void
): void {
const closeOnClickOutside = useCallback(
diff --git a/app/assets/javascripts/directives/functional/index.ts b/app/assets/javascripts/directives/functional/index.ts
index 0a8bd4c53..a2b83a7ba 100644
--- a/app/assets/javascripts/directives/functional/index.ts
+++ b/app/assets/javascripts/directives/functional/index.ts
@@ -3,7 +3,6 @@ export { clickOutside } from './click-outside';
export { delayHide } from './delay-hide';
export { elemReady } from './elemReady';
export { fileChange } from './file-change';
-export { infiniteScroll } from './infiniteScroll';
export { lowercase } from './lowercase';
export { selectOnFocus } from './selectOnFocus';
export { snEnter } from './snEnter';
diff --git a/app/assets/javascripts/directives/functional/infiniteScroll.ts b/app/assets/javascripts/directives/functional/infiniteScroll.ts
deleted file mode 100644
index 349a59946..000000000
--- a/app/assets/javascripts/directives/functional/infiniteScroll.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { debounce } from '@/utils';
-/* @ngInject */
-export function infiniteScroll() {
- return {
- link: function (scope: ng.IScope, elem: JQLite, attrs: any) {
- const scopeAny = scope as any;
- const offset = parseInt(attrs.threshold) || 0;
- const element = elem[0];
- scopeAny.paginate = debounce(() => {
- scope.$apply(attrs.infiniteScroll);
- }, 10);
- scopeAny.onScroll = () => {
- if (
- scope.$eval(attrs.canLoad) &&
- element.scrollTop + element.offsetHeight >= element.scrollHeight - offset
- ) {
- scopeAny.paginate();
- }
- };
- elem.on('scroll', scopeAny.onScroll);
- scope.$on('$destroy', () => {
- elem.off('scroll', scopeAny.onScroll);
- });
- }
- };
-}
diff --git a/app/assets/javascripts/preferences/PreferencesMenu.ts b/app/assets/javascripts/preferences/PreferencesMenu.ts
index 94cf73836..e52e81f0f 100644
--- a/app/assets/javascripts/preferences/PreferencesMenu.ts
+++ b/app/assets/javascripts/preferences/PreferencesMenu.ts
@@ -1,16 +1,20 @@
import { IconType } from '@/components/Icon';
import { action, makeAutoObservable, observable } from 'mobx';
import { ExtensionsLatestVersions } from '@/preferences/panes/extensions-segments';
-import { ContentType, SNComponent } from '@standardnotes/snjs';
+import {
+ ComponentArea,
+ ContentType,
+ FeatureIdentifier,
+ SNComponent,
+} from '@standardnotes/snjs';
import { WebApplication } from '@/ui_models/application';
-import { FeatureIdentifier } from '@standardnotes/features';
-import { ComponentArea } from '@standardnotes/snjs';
const PREFERENCE_IDS = [
'general',
'account',
'appearance',
'security',
+ 'backups',
'listed',
'shortcuts',
'accessibility',
@@ -37,6 +41,7 @@ const PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'appearance', label: 'Appearance', icon: 'themes' },
{ id: 'security', label: 'Security', icon: 'security' },
+ { id: 'backups', label: 'Backups', icon: 'restore' },
{ id: 'listed', label: 'Listed', icon: 'listed' },
{ id: 'shortcuts', label: 'Shortcuts', icon: 'keyboard' },
{ id: 'accessibility', label: 'Accessibility', icon: 'accessibility' },
@@ -48,6 +53,7 @@ const READY_PREFERENCES_MENU_ITEMS: PreferencesMenuItem[] = [
{ id: 'account', label: 'Account', icon: 'user' },
{ id: 'general', label: 'General', icon: 'settings' },
{ id: 'security', label: 'Security', icon: 'security' },
+ { id: 'backups', label: 'Backups', icon: 'restore' },
{ id: 'listed', label: 'Listed', icon: 'listed' },
{ id: 'help-feedback', label: 'Help & feedback', icon: 'help' },
];
@@ -101,6 +107,7 @@ export class PreferencesMenu {
FeatureIdentifier.TwoFactorAuthManager,
'org.standardnotes.batch-manager',
'org.standardnotes.extensions-manager',
+ FeatureIdentifier.CloudLink,
];
this._extensionPanes = (
this.application.getItems([
diff --git a/app/assets/javascripts/preferences/PreferencesView.tsx b/app/assets/javascripts/preferences/PreferencesView.tsx
index cd58f05a3..dd8173135 100644
--- a/app/assets/javascripts/preferences/PreferencesView.tsx
+++ b/app/assets/javascripts/preferences/PreferencesView.tsx
@@ -17,6 +17,7 @@ import { MfaProps } from './panes/two-factor-auth/MfaProps';
import { AppState } from '@/ui_models/app_state';
import { useEffect, useMemo } from 'preact/hooks';
import { ExtensionPane } from './panes/ExtensionPane';
+import { Backups } from '@/preferences/panes/Backups';
interface PreferencesProps extends MfaProps {
application: WebApplication;
@@ -26,16 +27,54 @@ interface PreferencesProps extends MfaProps {
const PaneSelector: FunctionComponent<
PreferencesProps & { menu: PreferencesMenu }
-> = observer(
- ({
- menu,
- appState,
- application,
- mfaProvider,
- userProvider
- }) => {
- switch (menu.selectedPaneId) {
- case 'general':
+> = observer(({ menu, appState, application, mfaProvider, userProvider }) => {
+ switch (menu.selectedPaneId) {
+ case 'general':
+ return (
+
+ );
+ case 'account':
+ return (
+
+ );
+ case 'appearance':
+ return null;
+ case 'security':
+ return (
+
+ );
+ case 'backups':
+ return ;
+ case 'listed':
+ return ;
+ case 'shortcuts':
+ return null;
+ case 'accessibility':
+ return null;
+ case 'get-free-month':
+ return null;
+ case 'help-feedback':
+ return ;
+ default:
+ if (menu.selectedExtension != undefined) {
+ return (
+
+ );
+ } else {
return (
);
- case 'account':
- return (
-
- );
- case 'appearance':
- return null;
- case 'security':
- return (
-
- );
- case 'listed':
- return ;
- case 'shortcuts':
- return null;
- case 'accessibility':
- return null;
- case 'get-free-month':
- return null;
- case 'help-feedback':
- return ;
- default:
- if (menu.selectedExtension != undefined) {
- return (
-
- );
- } else {
- return (
-
- );
- }
- }
- });
+ }
+ }
+});
const PreferencesCanvas: FunctionComponent<
PreferencesProps & { menu: PreferencesMenu }
@@ -105,8 +98,13 @@ const PreferencesCanvas: FunctionComponent<
export const PreferencesView: FunctionComponent = observer(
(props) => {
const menu = useMemo(
- () => new PreferencesMenu(props.application, props.appState.enableUnfinishedFeatures),
- [props.appState.enableUnfinishedFeatures, props.application]);
+ () =>
+ new PreferencesMenu(
+ props.application,
+ props.appState.enableUnfinishedFeatures
+ ),
+ [props.appState.enableUnfinishedFeatures, props.application]
+ );
useEffect(() => {
menu.selectPane(props.appState.preferences.currentPane);
diff --git a/app/assets/javascripts/preferences/panes/Backups.tsx b/app/assets/javascripts/preferences/panes/Backups.tsx
new file mode 100644
index 000000000..e4b5c03d6
--- /dev/null
+++ b/app/assets/javascripts/preferences/panes/Backups.tsx
@@ -0,0 +1,23 @@
+import { WebApplication } from '@/ui_models/application';
+import { AppState } from '@/ui_models/app_state';
+import { FunctionComponent } from 'preact';
+import { PreferencesPane } from '../components';
+import { CloudLink, DataBackups, EmailBackups } from './backups-segments';
+
+interface Props {
+ appState: AppState;
+ application: WebApplication;
+}
+
+export const Backups: FunctionComponent = ({
+ application,
+ appState,
+}) => {
+ return (
+
+
+
+
+
+ );
+};
diff --git a/app/assets/javascripts/preferences/panes/Security.tsx b/app/assets/javascripts/preferences/panes/Security.tsx
index 007dad57f..900940d7a 100644
--- a/app/assets/javascripts/preferences/panes/Security.tsx
+++ b/app/assets/javascripts/preferences/panes/Security.tsx
@@ -2,7 +2,7 @@ import { WebApplication } from '@/ui_models/application';
import { AppState } from '@/ui_models/app_state';
import { FunctionComponent } from 'preact';
import { PreferencesPane } from '../components';
-import { Encryption, PasscodeLock, Protections, DataBackups } from './security-segments';
+import { Encryption, PasscodeLock, Protections } from './security-segments';
import { TwoFactorAuthWrapper } from './two-factor-auth';
import { MfaProps } from './two-factor-auth/MfaProps';
@@ -20,6 +20,5 @@ export const Security: FunctionComponent = (props) => (
userProvider={props.userProvider}
/>
-
);
diff --git a/app/assets/javascripts/preferences/panes/account/offlineSubscription.tsx b/app/assets/javascripts/preferences/panes/account/offlineSubscription.tsx
index 173bd60a1..5744600c3 100644
--- a/app/assets/javascripts/preferences/panes/account/offlineSubscription.tsx
+++ b/app/assets/javascripts/preferences/panes/account/offlineSubscription.tsx
@@ -18,7 +18,7 @@ interface IProps {
}
export const OfflineSubscription: FunctionalComponent = observer(
- ({ application, appState }) => {
+ ({ application }) => {
const [activationCode, setActivationCode] = useState('');
const [isSuccessfullyActivated, setIsSuccessfullyActivated] =
useState(false);
@@ -33,7 +33,9 @@ export const OfflineSubscription: FunctionalComponent = observer(
}, [application]);
const shouldShowOfflineSubscription = () => {
- return !application.hasAccount() || application.isThirdPartyHostUsed();
+ return !application.hasAccount() ||
+ application.isThirdPartyHostUsed() ||
+ hasUserPreviouslyStoredCode;
};
const handleSubscriptionCodeSubmit = async (
diff --git a/app/assets/javascripts/preferences/panes/security-segments/DataBackups.tsx b/app/assets/javascripts/preferences/panes/backups-segments/DataBackups.tsx
similarity index 67%
rename from app/assets/javascripts/preferences/panes/security-segments/DataBackups.tsx
rename to app/assets/javascripts/preferences/panes/backups-segments/DataBackups.tsx
index 9dec8bcbc..2cedea433 100644
--- a/app/assets/javascripts/preferences/panes/security-segments/DataBackups.tsx
+++ b/app/assets/javascripts/preferences/panes/backups-segments/DataBackups.tsx
@@ -5,32 +5,68 @@ import {
STRING_INVALID_IMPORT_FILE,
STRING_IMPORTING_ZIP_FILE,
STRING_UNSUPPORTED_BACKUP_FILE_VERSION,
- StringImportError
+ StringImportError,
+ STRING_E2E_ENABLED,
+ STRING_LOCAL_ENC_ENABLED,
+ STRING_ENC_NOT_ENABLED,
} from '@/strings';
import { BackupFile } from '@standardnotes/snjs';
-import { useRef, useState } from 'preact/hooks';
+import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
import { WebApplication } from '@/ui_models/application';
import { JSXInternal } from 'preact/src/jsx';
import TargetedEvent = JSXInternal.TargetedEvent;
import { AppState } from '@/ui_models/app_state';
import { observer } from 'mobx-react-lite';
-import { PreferencesGroup, PreferencesSegment, Title, Text, Subtitle } from '../../components';
+import {
+ PreferencesGroup,
+ PreferencesSegment,
+ Title,
+ Text,
+ Subtitle,
+} from '../../components';
import { Button } from '@/components/Button';
type Props = {
application: WebApplication;
appState: AppState;
-}
-
-export const DataBackups = observer(({
- application,
- appState
-}: Props) => {
+};
+export const DataBackups = observer(({ application, appState }: Props) => {
const fileInputRef = useRef(null);
const [isImportDataLoading, setIsImportDataLoading] = useState(false);
+ const {
+ isBackupEncrypted,
+ isEncryptionEnabled,
+ setIsBackupEncrypted,
+ setIsEncryptionEnabled,
+ setEncryptionStatusString,
+ } = appState.accountMenu;
- const { isBackupEncrypted, isEncryptionEnabled, setIsBackupEncrypted } = appState.accountMenu;
+ const refreshEncryptionStatus = useCallback(() => {
+ const hasUser = application.hasAccount();
+ const hasPasscode = application.hasPasscode();
+
+ const encryptionEnabled = hasUser || hasPasscode;
+
+ const encryptionStatusString = hasUser
+ ? STRING_E2E_ENABLED
+ : hasPasscode
+ ? STRING_LOCAL_ENC_ENABLED
+ : STRING_ENC_NOT_ENABLED;
+
+ setEncryptionStatusString(encryptionStatusString);
+ setIsEncryptionEnabled(encryptionEnabled);
+ setIsBackupEncrypted(encryptionEnabled);
+ }, [
+ application,
+ setEncryptionStatusString,
+ setIsBackupEncrypted,
+ setIsEncryptionEnabled,
+ ]);
+
+ useEffect(() => {
+ refreshEncryptionStatus();
+ }, [refreshEncryptionStatus]);
const downloadDataArchive = () => {
application.getArchiveService().downloadBackup(isBackupEncrypted);
@@ -74,12 +110,14 @@ export const DataBackups = observer(({
statusText = StringImportError(result.errorCount);
}
void alertDialog({
- text: statusText
+ text: statusText,
});
};
- const importFileSelected = async (event: TargetedEvent) => {
- const { files } = (event.target as HTMLInputElement);
+ const importFileSelected = async (
+ event: TargetedEvent
+ ) => {
+ const { files } = event.target as HTMLInputElement;
if (!files) {
return;
@@ -90,15 +128,14 @@ export const DataBackups = observer(({
return;
}
- const version = data.version || data.keyParams?.version || data.auth_params?.version;
+ const version =
+ data.version || data.keyParams?.version || data.auth_params?.version;
if (!version) {
await performImport(data);
return;
}
- if (
- application.protocolService.supportedVersions().includes(version)
- ) {
+ if (application.protocolService.supportedVersions().includes(version)) {
await performImport(data);
} else {
setIsImportDataLoading(false);
@@ -107,7 +144,9 @@ export const DataBackups = observer(({
};
// Whenever "Import Backup" is either clicked or key-pressed, proceed the import
- const handleImportFile = (event: TargetedEvent | KeyboardEvent) => {
+ const handleImportFile = (
+ event: TargetedEvent | KeyboardEvent
+ ) => {
if (event instanceof KeyboardEvent) {
const { code } = event;
@@ -161,26 +200,33 @@ export const DataBackups = observer(({
)}
-
-
+
-
Import a previously saved backup file
-
-
+
+
- {isImportDataLoading &&
}
+ {isImportDataLoading && (
+
+ )}
-
-
>
);
diff --git a/app/assets/javascripts/preferences/panes/backups-segments/EmailBackups.tsx b/app/assets/javascripts/preferences/panes/backups-segments/EmailBackups.tsx
new file mode 100644
index 000000000..e9e6ffb48
--- /dev/null
+++ b/app/assets/javascripts/preferences/panes/backups-segments/EmailBackups.tsx
@@ -0,0 +1,188 @@
+import { convertStringifiedBooleanToBoolean, isDesktopApplication } from '@/utils';
+import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/strings';
+import { useCallback, useEffect, useState } from 'preact/hooks';
+import { WebApplication } from '@/ui_models/application';
+import { observer } from 'mobx-react-lite';
+import {
+ PreferencesGroup,
+ PreferencesSegment,
+ Subtitle,
+ Text,
+ Title,
+} from '../../components';
+import { EmailBackupFrequency, SettingName } from '@standardnotes/settings';
+import { Dropdown, DropdownItem } from '@/components/Dropdown';
+import { Switch } from '@/components/Switch';
+import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
+import { FeatureIdentifier } from '@standardnotes/features';
+import { FeatureStatus } from '@standardnotes/snjs';
+
+type Props = {
+ application: WebApplication;
+};
+
+export const EmailBackups = observer(({ application }: Props) => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [emailFrequency, setEmailFrequency] = useState
(
+ EmailBackupFrequency.Disabled
+ );
+ const [emailFrequencyOptions, setEmailFrequencyOptions] = useState<
+ DropdownItem[]
+ >([]);
+ const [isFailedBackupEmailMuted, setIsFailedBackupEmailMuted] =
+ useState(true);
+ const [isEntitledForEmailBackups, setIsEntitledForEmailBackups] =
+ useState(false);
+
+ const loadEmailFrequencySetting = useCallback(async () => {
+ setIsLoading(true);
+
+ try {
+ const userSettings = await application.listSettings();
+ setEmailFrequency(
+ (userSettings.EMAIL_BACKUP_FREQUENCY ||
+ EmailBackupFrequency.Disabled) as EmailBackupFrequency
+ );
+ setIsFailedBackupEmailMuted(
+ convertStringifiedBooleanToBoolean(
+ userSettings[SettingName.MuteFailedBackupsEmails] as string
+ )
+ );
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [application]);
+
+ useEffect(() => {
+ const emailBackupsFeatureStatus = application.getFeatureStatus(
+ FeatureIdentifier.DailyEmailBackup
+ );
+ setIsEntitledForEmailBackups(
+ emailBackupsFeatureStatus === FeatureStatus.Entitled
+ );
+
+ const frequencyOptions = [];
+ for (const frequency in EmailBackupFrequency) {
+ const frequencyValue =
+ EmailBackupFrequency[frequency as keyof typeof EmailBackupFrequency];
+ frequencyOptions.push({
+ value: frequencyValue,
+ label: application.getEmailBackupFrequencyOptionLabel(frequencyValue),
+ });
+ }
+ setEmailFrequencyOptions(frequencyOptions);
+
+ loadEmailFrequencySetting();
+ }, [application, loadEmailFrequencySetting]);
+
+ const updateSetting = async (
+ settingName: SettingName,
+ payload: string
+ ): Promise => {
+ try {
+ await application.updateSetting(settingName, payload);
+ return true;
+ } catch (e) {
+ application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING);
+ return false;
+ }
+ };
+
+ const updateEmailFrequency = async (frequency: EmailBackupFrequency) => {
+ const previousFrequency = emailFrequency;
+ setEmailFrequency(frequency);
+
+ const updateResult = await updateSetting(
+ SettingName.EmailBackupFrequency,
+ frequency
+ );
+ if (!updateResult) {
+ setEmailFrequency(previousFrequency);
+ }
+ };
+
+ const toggleMuteFailedBackupEmails = async () => {
+ const previousValue = isFailedBackupEmailMuted;
+ setIsFailedBackupEmailMuted(!isFailedBackupEmailMuted);
+
+ const updateResult = await updateSetting(
+ SettingName.MuteFailedBackupsEmails,
+ `${!isFailedBackupEmailMuted}`
+ );
+ if (!updateResult) {
+ setIsFailedBackupEmailMuted(previousValue);
+ }
+ };
+
+ return (
+
+
+ Email Backups
+ {!isEntitledForEmailBackups && (
+ <>
+
+ A Plus or{' '}
+ Pro subscription plan is
+ required to enable Email Backups.{' '}
+
+ Learn more
+
+ .
+
+
+ >
+ )}
+
+ {!isDesktopApplication() && (
+
+ Daily encrypted email backups of your entire data set delivered
+ directly to your inbox.
+
+ )}
+
Email frequency
+
How often to receive backups.
+
+ {isLoading ? (
+
+ ) : (
+
{
+ updateEmailFrequency(item as EmailBackupFrequency);
+ }}
+ />
+ )}
+
+
+
Email preferences
+
+
+
+ Receive a notification email if an email backup fails.
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+});
diff --git a/app/assets/javascripts/preferences/panes/backups-segments/cloud-backups/CloudBackupProvider.tsx b/app/assets/javascripts/preferences/panes/backups-segments/cloud-backups/CloudBackupProvider.tsx
new file mode 100644
index 000000000..ab09088fa
--- /dev/null
+++ b/app/assets/javascripts/preferences/panes/backups-segments/cloud-backups/CloudBackupProvider.tsx
@@ -0,0 +1,222 @@
+import React from 'react';
+import { useCallback, useEffect, useState } from 'preact/hooks';
+import { ButtonType, SettingName } from '@standardnotes/snjs';
+import {
+ CloudProvider,
+ DropboxBackupFrequency,
+ GoogleDriveBackupFrequency,
+ OneDriveBackupFrequency
+} from '@standardnotes/settings';
+import { WebApplication } from '@/ui_models/application';
+import { Button } from '@/components/Button';
+import { isDev, openInNewTab } from '@/utils';
+import { Subtitle } from '@/preferences/components';
+import { KeyboardKey } from '@Services/ioService';
+import { FunctionComponent } from 'preact';
+
+type Props = {
+ application: WebApplication;
+ providerName: CloudProvider;
+};
+
+export const CloudBackupProvider: FunctionComponent = ({
+ application,
+ providerName
+ }) => {
+ const [authBegan, setAuthBegan] = useState(false);
+ const [successfullyInstalled, setSuccessfullyInstalled] = useState(false);
+ const [backupFrequency, setBackupFrequency] = useState(null);
+ const [confirmation, setConfirmation] = useState('');
+
+ const disable = async (event: Event) => {
+ event.stopPropagation();
+
+ try {
+ const shouldDisable = await application.alertService
+ .confirm(
+ 'Are you sure you want to disable this integration?',
+ 'Disable?',
+ 'Disable',
+ ButtonType.Danger,
+ 'Cancel'
+ );
+ if (shouldDisable) {
+ await application.deleteSetting(backupFrequencySettingName);
+ await application.deleteSetting(backupTokenSettingName);
+
+ setBackupFrequency(null);
+ }
+ } catch (error) {
+ application.alertService.alert(error as string);
+ }
+ };
+
+ const installIntegration = (event: Event) => {
+ event.stopPropagation();
+
+ const authUrl = application.getCloudProviderIntegrationUrl(providerName, isDev);
+ openInNewTab(authUrl);
+ setAuthBegan(true);
+ };
+
+ const performBackupNow = async () => {
+ // A backup is performed anytime the setting is updated with the integration token, so just update it here
+ try {
+ await application.updateSetting(backupFrequencySettingName, backupFrequency as string);
+ application.alertService.alert(
+ 'A backup has been triggered for this provider. Please allow a couple minutes for your backup to be processed.'
+ );
+ } catch (err) {
+ application.alertService.alert(
+ 'There was an error while trying to trigger a backup for this provider. Please try again.'
+ );
+ }
+ };
+
+ const backupSettingsData = {
+ [CloudProvider.Dropbox]: {
+ backupTokenSettingName: SettingName.DropboxBackupToken,
+ backupFrequencySettingName: SettingName.DropboxBackupFrequency,
+ defaultBackupFrequency: DropboxBackupFrequency.Daily
+ },
+ [CloudProvider.Google]: {
+ backupTokenSettingName: SettingName.GoogleDriveBackupToken,
+ backupFrequencySettingName: SettingName.GoogleDriveBackupFrequency,
+ defaultBackupFrequency: GoogleDriveBackupFrequency.Daily
+ },
+ [CloudProvider.OneDrive]: {
+ backupTokenSettingName: SettingName.OneDriveBackupToken,
+ backupFrequencySettingName: SettingName.OneDriveBackupFrequency,
+ defaultBackupFrequency: OneDriveBackupFrequency.Daily
+ }
+ };
+ const { backupTokenSettingName, backupFrequencySettingName, defaultBackupFrequency } = backupSettingsData[providerName];
+
+ const getCloudProviderIntegrationTokenFromUrl = (url: URL) => {
+ const urlSearchParams = new URLSearchParams(url.search);
+ let integrationTokenKeyInUrl = '';
+
+ switch (providerName) {
+ case CloudProvider.Dropbox:
+ integrationTokenKeyInUrl = 'dbt';
+ break;
+ case CloudProvider.Google:
+ integrationTokenKeyInUrl = 'key';
+ break;
+ case CloudProvider.OneDrive:
+ integrationTokenKeyInUrl = 'key';
+ break;
+ default:
+ throw new Error('Invalid Cloud Provider name');
+ }
+ return urlSearchParams.get(integrationTokenKeyInUrl);
+ };
+
+ const handleKeyPress = async (event: KeyboardEvent) => {
+ if (event.key === KeyboardKey.Enter) {
+ try {
+ const decryptedCode = atob(confirmation);
+ const urlFromDecryptedCode = new URL(decryptedCode);
+ const cloudProviderToken =
+ getCloudProviderIntegrationTokenFromUrl(urlFromDecryptedCode);
+
+ if (!cloudProviderToken) {
+ throw new Error();
+ }
+ await application.updateSetting(backupTokenSettingName, cloudProviderToken);
+ await application.updateSetting(backupFrequencySettingName, defaultBackupFrequency);
+
+ setBackupFrequency(defaultBackupFrequency);
+
+ setAuthBegan(false);
+ setSuccessfullyInstalled(true);
+ setConfirmation('');
+
+ await application.alertService.alert(
+ `${providerName} has been successfully installed. Your first backup has also been queued and should be reflected in your external cloud's folder within the next few minutes.`
+ );
+ } catch (e) {
+ await application.alertService.alert('Invalid code. Please try again.');
+ }
+ }
+ };
+
+ const handleChange = (event: Event) => {
+ setConfirmation((event.target as HTMLInputElement).value);
+ };
+
+ const getIntegrationStatus = useCallback(async () => {
+ const frequency = await application.getSetting(backupFrequencySettingName);
+ setBackupFrequency(frequency);
+ }, [application, backupFrequencySettingName]);
+
+ useEffect(() => {
+ getIntegrationStatus();
+ }, [getIntegrationStatus]);
+
+ const isExpanded = authBegan || successfullyInstalled;
+ const shouldShowEnableButton = !backupFrequency && !authBegan;
+
+ return (
+
+
+
{providerName}
+
+ {successfullyInstalled && (
+
{providerName} has been successfully enabled.
+ )}
+
+ {authBegan && (
+
+
+ Complete authentication from the newly opened window. Upon
+ completion, a confirmation code will be displayed. Enter this code
+ below:
+
+
+
+
+
+ )}
+ {shouldShowEnableButton && (
+
+
+
+ )}
+
+ {backupFrequency && (
+
+
+
+
+ )}
+
+ );
+};
diff --git a/app/assets/javascripts/preferences/panes/backups-segments/cloud-backups/index.tsx b/app/assets/javascripts/preferences/panes/backups-segments/cloud-backups/index.tsx
new file mode 100644
index 000000000..19dc52ffe
--- /dev/null
+++ b/app/assets/javascripts/preferences/panes/backups-segments/cloud-backups/index.tsx
@@ -0,0 +1,150 @@
+import React from 'react';
+import { CloudBackupProvider } from './CloudBackupProvider';
+import { useCallback, useEffect, useState } from 'preact/hooks';
+import { WebApplication } from '@/ui_models/application';
+import {
+ PreferencesGroup,
+ PreferencesSegment, Subtitle,
+ Text,
+ Title
+} from '@/preferences/components';
+import { HorizontalSeparator } from '@/components/shared/HorizontalSeparator';
+import { FeatureIdentifier } from '@standardnotes/features';
+import { FeatureStatus } from '@standardnotes/snjs';
+import { FunctionComponent } from 'preact';
+import { CloudProvider, EmailBackupFrequency, SettingName } from '@standardnotes/settings';
+import { Switch } from '@/components/Switch';
+import { convertStringifiedBooleanToBoolean } from '@/utils';
+import { STRING_FAILED_TO_UPDATE_USER_SETTING } from '@/strings';
+
+const providerData = [{
+ name: CloudProvider.Dropbox
+}, {
+ name: CloudProvider.Google
+}, {
+ name: CloudProvider.OneDrive
+}
+];
+
+type Props = {
+ application: WebApplication;
+};
+
+export const CloudLink: FunctionComponent = ({ application }) => {
+ const [isEntitledForCloudBackups, setIsEntitledForCloudBackups] = useState(false);
+ const [isFailedCloudBackupEmailMuted, setIsFailedCloudBackupEmailMuted] = useState(true);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const loadIsFailedCloudBackupEmailMutedSetting = useCallback(async () => {
+ setIsLoading(true);
+
+ try {
+ const userSettings = await application.listSettings();
+ setIsFailedCloudBackupEmailMuted(
+ convertStringifiedBooleanToBoolean(
+ userSettings[SettingName.MuteFailedCloudBackupsEmails] as string
+ )
+ );
+ } catch (error) {
+ console.error(error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [application]);
+
+ useEffect(() => {
+ const cloudBackupsFeatureStatus = application.getFeatureStatus(
+ FeatureIdentifier.CloudLink
+ );
+ setIsEntitledForCloudBackups(
+ cloudBackupsFeatureStatus === FeatureStatus.Entitled
+ );
+ loadIsFailedCloudBackupEmailMutedSetting();
+ }, [application, loadIsFailedCloudBackupEmailMutedSetting]);
+
+ const updateSetting = async (
+ settingName: SettingName,
+ payload: string
+ ): Promise => {
+ try {
+ await application.updateSetting(settingName, payload);
+ return true;
+ } catch (e) {
+ application.alertService.alert(STRING_FAILED_TO_UPDATE_USER_SETTING);
+ return false;
+ }
+ };
+
+ const toggleMuteFailedCloudBackupEmails = async () => {
+ const previousValue = isFailedCloudBackupEmailMuted;
+ setIsFailedCloudBackupEmailMuted(!isFailedCloudBackupEmailMuted);
+
+ const updateResult = await updateSetting(
+ SettingName.MuteFailedCloudBackupsEmails,
+ `${!isFailedCloudBackupEmailMuted}`
+ );
+ if (!updateResult) {
+ setIsFailedCloudBackupEmailMuted(previousValue);
+ }
+ };
+
+ return (
+
+
+ Cloud Backups
+ {!isEntitledForCloudBackups && (
+ <>
+
+ A Plus or{' '}
+ Pro subscription plan is
+ required to enable Cloud Backups.{' '}
+
+ Learn more
+
+ .
+
+
+ >
+ )}
+
+
+ Configure the integrations below to enable automatic daily backups
+ of your encrypted data set to your third-party cloud provider.
+
+
+
+
+ {providerData.map(({ name }) => (
+ <>
+
+
+ >
+ ))}
+
+
+
+
Email preferences
+
+
+ Receive a notification email if a cloud backup fails.
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+};
diff --git a/app/assets/javascripts/preferences/panes/backups-segments/index.ts b/app/assets/javascripts/preferences/panes/backups-segments/index.ts
new file mode 100644
index 000000000..a890d9b94
--- /dev/null
+++ b/app/assets/javascripts/preferences/panes/backups-segments/index.ts
@@ -0,0 +1,3 @@
+export * from './DataBackups';
+export * from './EmailBackups';
+export * from './cloud-backups';
diff --git a/app/assets/javascripts/preferences/panes/extensions-segments/ExtensionItem.tsx b/app/assets/javascripts/preferences/panes/extensions-segments/ExtensionItem.tsx
index ade893a0c..4c4e285f2 100644
--- a/app/assets/javascripts/preferences/panes/extensions-segments/ExtensionItem.tsx
+++ b/app/assets/javascripts/preferences/panes/extensions-segments/ExtensionItem.tsx
@@ -1,6 +1,5 @@
import { FunctionComponent } from "preact";
import { SNComponent } from "@standardnotes/snjs";
-import { ComponentArea } from "@standardnotes/features";
import { PreferencesSegment, SubtitleLight, Title } from "@/preferences/components";
import { Switch } from "@/components/Switch";
import { WebApplication } from "@/ui_models/application";
@@ -27,28 +26,10 @@ export interface ExtensionItemProps {
}
export const ExtensionItem: FunctionComponent =
- ({ application, extension, first, uninstall, toggleActivate, latestVersion }) => {
- const [autoupdateDisabled, setAutoupdateDisabled] = useState(extension.autoupdateDisabled ?? false);
+ ({ application, extension, first, uninstall}) => {
const [offlineOnly, setOfflineOnly] = useState(extension.offlineOnly ?? false);
const [extensionName, setExtensionName] = useState(extension.name);
- const toggleAutoupdate = () => {
- const newAutoupdateValue = !autoupdateDisabled;
- setAutoupdateDisabled(newAutoupdateValue);
- application
- .changeAndSaveItem(extension.uuid, (m: any) => {
- if (m.content == undefined) m.content = {};
- m.content.autoupdateDisabled = newAutoupdateValue;
- })
- .then((item) => {
- const component = (item as SNComponent);
- setAutoupdateDisabled(component.autoupdateDisabled);
- })
- .catch(e => {
- console.error(e);
- });
- };
-
const toggleOffllineOnly = () => {
const newOfflineOnly = !offlineOnly;
setOfflineOnly(newOfflineOnly);
@@ -80,6 +61,7 @@ export const ExtensionItem: FunctionComponent =
};
const localInstallable = extension.package_info.download_url;
+ const isThirParty = application.isThirdPartyFeature(extension.identifier);
return (
@@ -91,7 +73,7 @@ export const ExtensionItem: FunctionComponent =
- {localInstallable && }
+ {isThirParty && localInstallable && }
<>
diff --git a/app/assets/javascripts/preferences/panes/general-segments/Defaults.tsx b/app/assets/javascripts/preferences/panes/general-segments/Defaults.tsx
index 394050a5c..8c83be5d9 100644
--- a/app/assets/javascripts/preferences/panes/general-segments/Defaults.tsx
+++ b/app/assets/javascripts/preferences/panes/general-segments/Defaults.tsx
@@ -21,32 +21,33 @@ type Props = {
application: WebApplication;
};
-type EditorOption = {
- icon?: IconType;
- label: string;
+type EditorOption = DropdownItem & {
value: FeatureIdentifier | 'plain-editor';
};
-const getEditorIconType = (identifier: string): IconType | null => {
+export const getIconAndTintForEditor = (
+ identifier: FeatureIdentifier | undefined
+): [IconType, number] => {
switch (identifier) {
case FeatureIdentifier.BoldEditor:
case FeatureIdentifier.PlusEditor:
- return 'rich-text';
+ return ['rich-text', 1];
case FeatureIdentifier.MarkdownBasicEditor:
case FeatureIdentifier.MarkdownMathEditor:
case FeatureIdentifier.MarkdownMinimistEditor:
case FeatureIdentifier.MarkdownProEditor:
- return 'markdown';
+ return ['markdown', 2];
case FeatureIdentifier.TokenVaultEditor:
- return 'authenticator';
+ return ['authenticator', 6];
case FeatureIdentifier.SheetsEditor:
- return 'spreadsheets';
+ return ['spreadsheets', 5];
case FeatureIdentifier.TaskEditor:
- return 'tasks';
+ return ['tasks', 3];
case FeatureIdentifier.CodeEditor:
- return 'code';
+ return ['code', 4];
+ default:
+ return ['plain-text', 1];
}
- return null;
};
const makeEditorDefault = (
@@ -91,17 +92,19 @@ export const Defaults: FunctionComponent = ({ application }) => {
.componentsForArea(ComponentArea.Editor)
.map((editor): EditorOption => {
const identifier = editor.package_info.identifier;
- const iconType = getEditorIconType(identifier);
+ const [iconType, tint] = getIconAndTintForEditor(identifier);
return {
label: editor.name,
value: identifier,
...(iconType ? { icon: iconType } : null),
+ ...(tint ? { iconClassName: `color-accessory-tint-${tint}` } : null),
};
})
.concat([
{
icon: 'plain-text',
+ iconClassName: `color-accessory-tint-1`,
label: 'Plain Editor',
value: 'plain-editor',
},
diff --git a/app/assets/javascripts/preferences/panes/general-segments/Tools.tsx b/app/assets/javascripts/preferences/panes/general-segments/Tools.tsx
index 380a93b7b..b0e21a623 100644
--- a/app/assets/javascripts/preferences/panes/general-segments/Tools.tsx
+++ b/app/assets/javascripts/preferences/panes/general-segments/Tools.tsx
@@ -72,8 +72,8 @@ export const Tools: FunctionalComponent = observer(
Spellcheck
- May degrade performance, especially with long notes. Available
- in the Plain Text editor and most specialty editors.
+ May degrade performance, especially with long notes. This option only controls
+ spellcheck in the Plain Editor.
diff --git a/app/assets/javascripts/preferences/panes/security-segments/index.ts b/app/assets/javascripts/preferences/panes/security-segments/index.ts
index 75a109522..38694cc50 100644
--- a/app/assets/javascripts/preferences/panes/security-segments/index.ts
+++ b/app/assets/javascripts/preferences/panes/security-segments/index.ts
@@ -1,4 +1,3 @@
export * from './Encryption';
export * from './PasscodeLock';
export * from './Protections';
-export * from './DataBackups';
diff --git a/app/assets/javascripts/services/themeManager.ts b/app/assets/javascripts/services/themeManager.ts
index e3c9be599..281627b86 100644
--- a/app/assets/javascripts/services/themeManager.ts
+++ b/app/assets/javascripts/services/themeManager.ts
@@ -9,6 +9,7 @@ import {
ContentType,
UuidString,
FeatureStatus,
+ PayloadSource,
} from '@standardnotes/snjs';
const CACHED_THEMES_KEY = 'cachedThemes';
@@ -22,6 +23,11 @@ export class ThemeManager extends ApplicationService {
super.onAppEvent(event);
if (event === ApplicationEvent.SignedOut) {
this.deactivateAllThemes();
+ this.activeThemes = [];
+ this.application?.removeValue(
+ CACHED_THEMES_KEY,
+ StorageValueModes.Nonwrapped
+ );
} else if (event === ApplicationEvent.StorageReady) {
await this.activateCachedThemes();
} else if (event === ApplicationEvent.FeaturesUpdated) {
@@ -34,7 +40,7 @@ export class ThemeManager extends ApplicationService {
}
deinit() {
- this.clearAppThemeState();
+ this.deactivateAllThemes();
this.activeThemes.length = 0;
this.unregisterDesktop();
this.unregisterStream();
@@ -43,7 +49,8 @@ export class ThemeManager extends ApplicationService {
super.deinit();
}
- reloadThemeStatus(): void {
+ private reloadThemeStatus(): void {
+ let hasChange = false;
for (const themeUuid of this.activeThemes) {
const theme = this.application.findItem(themeUuid) as SNTheme;
if (
@@ -52,8 +59,13 @@ export class ThemeManager extends ApplicationService {
FeatureStatus.Entitled
) {
this.deactivateTheme(themeUuid);
+ hasChange = true;
}
}
+
+ if (hasChange) {
+ this.cacheThemeState();
+ }
}
/** @override */
@@ -64,9 +76,8 @@ export class ThemeManager extends ApplicationService {
private async activateCachedThemes() {
const cachedThemes = await this.getCachedThemes();
- const writeToCache = false;
for (const theme of cachedThemes) {
- this.activateTheme(theme, writeToCache);
+ this.activateTheme(theme);
}
}
@@ -78,16 +89,15 @@ export class ThemeManager extends ApplicationService {
this.deactivateTheme(component.uuid);
setTimeout(() => {
this.activateTheme(component as SNTheme);
+ this.cacheThemeState();
}, 10);
}
});
this.unregisterStream = this.application.streamItems(
ContentType.Theme,
- () => {
- const themes = this.application.getDisplayableItems(
- ContentType.Theme
- ) as SNTheme[];
+ (items, source) => {
+ const themes = items as SNTheme[];
for (const theme of themes) {
if (theme.active) {
this.activateTheme(theme);
@@ -95,23 +105,21 @@ export class ThemeManager extends ApplicationService {
this.deactivateTheme(theme.uuid);
}
}
+ if (source !== PayloadSource.LocalRetrieved) {
+ this.cacheThemeState();
+ }
}
);
}
- private clearAppThemeState() {
- for (const uuid of this.activeThemes) {
- this.deactivateTheme(uuid, false);
+ private deactivateAllThemes() {
+ const activeThemes = this.activeThemes.slice();
+ for (const uuid of activeThemes) {
+ this.deactivateTheme(uuid);
}
}
- private deactivateAllThemes() {
- this.clearAppThemeState();
- this.activeThemes = [];
- this.decacheThemes();
- }
-
- private activateTheme(theme: SNTheme, writeToCache = true) {
+ private activateTheme(theme: SNTheme) {
if (this.activeThemes.find((uuid) => uuid === theme.uuid)) {
return;
}
@@ -128,24 +136,19 @@ export class ThemeManager extends ApplicationService {
link.media = 'screen,print';
link.id = theme.uuid;
document.getElementsByTagName('head')[0].appendChild(link);
- if (writeToCache) {
- this.cacheThemes();
- }
}
- private deactivateTheme(uuid: string, recache = true) {
+ private deactivateTheme(uuid: string) {
const element = document.getElementById(uuid) as HTMLLinkElement;
if (element) {
element.disabled = true;
- element.parentNode!.removeChild(element);
+ element.parentNode?.removeChild(element);
}
+
removeFromArray(this.activeThemes, uuid);
- if (recache) {
- this.cacheThemes();
- }
}
- private async cacheThemes() {
+ private async cacheThemeState() {
const themes = this.application.getAll(this.activeThemes) as SNTheme[];
const mapped = await Promise.all(
themes.map(async (theme) => {
@@ -165,15 +168,6 @@ export class ThemeManager extends ApplicationService {
);
}
- private async decacheThemes() {
- if (this.application) {
- return this.application.removeValue(
- CACHED_THEMES_KEY,
- StorageValueModes.Nonwrapped
- );
- }
- }
-
private async getCachedThemes() {
const cachedThemes = (await this.application.getValue(
CACHED_THEMES_KEY,
diff --git a/app/assets/javascripts/strings.ts b/app/assets/javascripts/strings.ts
index 1f6f647d8..5b4e19bda 100644
--- a/app/assets/javascripts/strings.ts
+++ b/app/assets/javascripts/strings.ts
@@ -22,14 +22,9 @@ export const STRING_NEW_UPDATE_READY =
export const STRING_DELETE_TAG =
'Are you sure you want to delete this tag? Note: deleting a tag will not delete its notes.';
+export const STRING_MISSING_SYSTEM_TAG = 'We are missing a System Tag.';
+
/** @editor */
-export const STRING_SAVING_WHILE_DOCUMENT_HIDDEN =
- 'Attempting to save an item while the application is hidden. To protect data integrity, please refresh the application window and try again.';
-export const STRING_DELETED_NOTE =
- 'The note you are attempting to edit has been deleted, and is awaiting sync. Changes you make will be disregarded.';
-export const STRING_INVALID_NOTE =
- "The note you are attempting to save can not be found or has been deleted. Changes you make will not be synced. Please copy this note's text and start a new note.";
-export const STRING_ELLIPSES = '...';
export const STRING_GENERIC_SAVE_ERROR =
'There was an error saving your note. Please try again.';
export const STRING_DELETE_PLACEHOLDER_ATTEMPT =
@@ -116,6 +111,9 @@ export const STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON = 'Upgrade';
export const STRING_REMOVE_OFFLINE_KEY_CONFIRMATION =
'This will delete the previously saved offline key.';
+export const STRING_FAILED_TO_UPDATE_USER_SETTING =
+ 'There was an error while trying to update your settings. Please try again.';
+
export const Strings = {
protectingNoteWithoutProtectionSources:
'Access to this note will not be restricted until you set up a passcode or account.',
diff --git a/app/assets/javascripts/ui_models/app_state/app_state.ts b/app/assets/javascripts/ui_models/app_state/app_state.ts
index 71d4440f0..84128a8c6 100644
--- a/app/assets/javascripts/ui_models/app_state/app_state.ts
+++ b/app/assets/javascripts/ui_models/app_state/app_state.ts
@@ -2,24 +2,30 @@ import { Bridge } from '@/services/bridge';
import { storage, StorageKey } from '@/services/localStorage';
import { WebApplication } from '@/ui_models/application';
import { AccountMenuState } from '@/ui_models/app_state/account_menu_state';
-import { NoteViewController } from '@/views/note_view/note_view_controller';
import { isDesktopApplication } from '@/utils';
import {
ApplicationEvent,
+ ComponentArea,
ContentType,
DeinitSource,
+ isPayloadSourceInternalChange,
PayloadSource,
PrefKey,
+ SNComponent,
SNNote,
+ SNSmartTag,
+ ComponentViewer,
SNTag,
+ NoteViewController,
} from '@standardnotes/snjs';
import pull from 'lodash/pull';
import {
action,
computed,
+ IReactionDisposer,
makeObservable,
observable,
- runInAction,
+ reaction,
} from 'mobx';
import { ActionsMenuState } from './actions_menu_state';
import { FeaturesState } from './features_state';
@@ -72,11 +78,6 @@ export class AppState {
onVisibilityChange: any;
showBetaWarning: boolean;
- selectedTag: SNTag | undefined;
- previouslySelectedTag: SNTag | undefined;
- editingTag: SNTag | undefined;
- _templateTag: SNTag | undefined;
-
private multiEditorSupport = false;
readonly quickSettingsMenu = new QuickSettingsState();
@@ -92,10 +93,16 @@ export class AppState {
readonly features: FeaturesState;
readonly tags: TagsState;
readonly notesView: NotesViewState;
+
+ public foldersComponentViewer?: ComponentViewer;
+
isSessionsModalVisible = false;
private appEventObserverRemovers: (() => void)[] = [];
+ private readonly tagChangedDisposer: IReactionDisposer;
+ private readonly foldersComponentViewerDisposer: () => void;
+
/* @ngInject */
constructor(
$rootScope: ng.IRootScopeService,
@@ -160,30 +167,27 @@ export class AppState {
this.showBetaWarning = false;
}
- this.selectedTag = undefined;
- this.previouslySelectedTag = undefined;
- this.editingTag = undefined;
- this._templateTag = undefined;
+ this.foldersComponentViewer = undefined;
makeObservable(this, {
+ selectedTag: computed,
+
showBetaWarning: observable,
isSessionsModalVisible: observable,
preferences: observable,
- selectedTag: observable,
- previouslySelectedTag: observable,
- _templateTag: observable,
- templateTag: computed,
- createNewTag: action,
- editingTag: observable,
- setSelectedTag: action,
- removeTag: action,
-
enableBetaWarning: action,
disableBetaWarning: action,
openSessionsModal: action,
closeSessionsModal: action,
+
+ foldersComponentViewer: observable.ref,
+ setFoldersComponent: action,
});
+
+ this.tagChangedDisposer = this.tagChangedNotifier();
+ this.foldersComponentViewerDisposer =
+ this.subscribeToFoldersComponentChanges();
}
deinit(source: DeinitSource): void {
@@ -206,6 +210,8 @@ export class AppState {
}
document.removeEventListener('visibilitychange', this.onVisibilityChange);
this.onVisibilityChange = undefined;
+ this.tagChangedDisposer();
+ this.foldersComponentViewerDisposer();
}
openSessionsModal(): void {
@@ -234,16 +240,16 @@ export class AppState {
if (!this.multiEditorSupport) {
this.closeActiveNoteController();
}
- const activeTagUuid = this.selectedTag
- ? this.selectedTag.isSmartTag
- ? undefined
- : this.selectedTag.uuid
- : undefined;
+
+ const selectedTag = this.selectedTag;
+
+ const activeRegularTagUuid =
+ selectedTag && !selectedTag.isSmartTag ? selectedTag.uuid : undefined;
await this.application.noteControllerGroup.createNoteView(
undefined,
title,
- activeTagUuid
+ activeRegularTagUuid
);
}
@@ -275,10 +281,88 @@ export class AppState {
}
}
+ private tagChangedNotifier(): IReactionDisposer {
+ return reaction(
+ () => this.tags.selectedUuid,
+ () => {
+ const tag = this.tags.selected;
+ const previousTag = this.tags.previouslySelected;
+
+ if (!tag) {
+ return;
+ }
+
+ if (this.application.isTemplateItem(tag)) {
+ return;
+ }
+
+ this.notifyEvent(AppStateEvent.TagChanged, {
+ tag,
+ previousTag,
+ });
+ }
+ );
+ }
+
+ async setFoldersComponent(component?: SNComponent) {
+ const foldersComponentViewer = this.foldersComponentViewer;
+
+ if (foldersComponentViewer) {
+ this.application.componentManager.destroyComponentViewer(
+ foldersComponentViewer
+ );
+ this.foldersComponentViewer = undefined;
+ }
+
+ if (component) {
+ this.foldersComponentViewer =
+ this.application.componentManager.createComponentViewer(
+ component,
+ undefined,
+ this.tags.onFoldersComponentMessage.bind(this.tags)
+ );
+ }
+ }
+
+ private subscribeToFoldersComponentChanges() {
+ return this.application.streamItems(
+ [ContentType.Component],
+ async (items, source) => {
+ if (
+ isPayloadSourceInternalChange(source) ||
+ source === PayloadSource.InitialObserverRegistrationPush
+ ) {
+ return;
+ }
+ const components = items as SNComponent[];
+ const hasFoldersChange = !!components.find(
+ (component) => component.area === ComponentArea.TagsList
+ );
+ if (hasFoldersChange) {
+ const componentViewer = this.application.componentManager
+ .componentsForArea(ComponentArea.TagsList)
+ .find((component) => component.active);
+
+ this.setFoldersComponent(componentViewer);
+ }
+ }
+ );
+ }
+
+ public get selectedTag(): SNTag | SNSmartTag | undefined {
+ return this.tags.selected;
+ }
+
+ public set selectedTag(tag: SNTag | SNSmartTag | undefined) {
+ this.tags.selected = tag;
+ }
+
streamNotesAndTags() {
this.application.streamItems(
[ContentType.Note, ContentType.Tag],
async (items, source) => {
+ const selectedTag = this.tags.selected;
+
/** Close any note controllers for deleted/trashed/archived notes */
if (source === PayloadSource.PreSyncSave) {
const notes = items.filter(
@@ -293,13 +377,13 @@ export class AppState {
this.closeNoteController(noteController);
} else if (
note.trashed &&
- !this.selectedTag?.isTrashTag &&
+ !selectedTag?.isTrashTag &&
!this.searchOptions.includeTrashed
) {
this.closeNoteController(noteController);
} else if (
note.archived &&
- !this.selectedTag?.isArchiveTag &&
+ !selectedTag?.isArchiveTag &&
!this.searchOptions.includeArchived &&
!this.application.getPreference(PrefKey.NotesShowArchived, false)
) {
@@ -307,17 +391,6 @@ export class AppState {
}
}
}
- if (this.selectedTag) {
- const matchingTag = items.find(
- (candidate) =>
- this.selectedTag && candidate.uuid === this.selectedTag.uuid
- );
- if (matchingTag) {
- runInAction(() => {
- this.selectedTag = matchingTag as SNTag;
- });
- }
- }
}
);
}
@@ -385,74 +458,6 @@ export class AppState {
});
}
- setSelectedTag(tag: SNTag) {
- if (tag.conflictOf) {
- this.application.changeAndSaveItem(tag.uuid, (mutator) => {
- mutator.conflictOf = undefined;
- });
- }
-
- if (this.selectedTag === tag) {
- return;
- }
-
- this.previouslySelectedTag = this.selectedTag;
- this.selectedTag = tag;
-
- if (this.templateTag?.uuid === tag.uuid) {
- return;
- }
-
- this.notifyEvent(AppStateEvent.TagChanged, {
- tag: tag,
- previousTag: this.previouslySelectedTag,
- });
- }
-
- public getSelectedTag() {
- return this.selectedTag;
- }
-
- public get templateTag(): SNTag | undefined {
- return this._templateTag;
- }
-
- public set templateTag(tag: SNTag | undefined) {
- const previous = this._templateTag;
- this._templateTag = tag;
-
- if (tag) {
- this.setSelectedTag(tag);
- this.editingTag = tag;
- } else if (previous) {
- this.selectedTag =
- previous === this.selectedTag ? undefined : this.selectedTag;
- this.editingTag =
- previous === this.editingTag ? undefined : this.editingTag;
- }
- }
-
- public removeTag(tag: SNTag) {
- this.application.deleteItem(tag);
- this.setSelectedTag(this.tags.smartTags[0]);
- }
-
- public async createNewTag() {
- if (this.templateTag) {
- return;
- }
-
- const newTag = (await this.application.createTemplateItem(
- ContentType.Tag
- )) as SNTag;
- this.templateTag = newTag;
- }
-
- public async undoCreateNewTag() {
- const previousTag = this.previouslySelectedTag || this.tags.smartTags[0];
- this.setSelectedTag(previousTag);
- }
-
/** Returns the tags that are referncing this note */
public getNoteTags(note: SNNote) {
return this.application.referencingForItem(note).filter((ref) => {
diff --git a/app/assets/javascripts/ui_models/app_state/features_state.ts b/app/assets/javascripts/ui_models/app_state/features_state.ts
index ca022d8b4..323f2931f 100644
--- a/app/assets/javascripts/ui_models/app_state/features_state.ts
+++ b/app/assets/javascripts/ui_models/app_state/features_state.ts
@@ -3,13 +3,22 @@ import {
FeatureIdentifier,
FeatureStatus,
} from '@standardnotes/snjs';
-import { computed, makeObservable, observable, runInAction } from 'mobx';
+import {
+ action,
+ computed,
+ makeObservable,
+ observable,
+ runInAction,
+ when,
+} from 'mobx';
import { WebApplication } from '../application';
export const TAG_FOLDERS_FEATURE_NAME = 'Tag folders';
export const TAG_FOLDERS_FEATURE_TOOLTIP =
'A Plus or Pro plan is required to enable Tag folders.';
+export const SMART_TAGS_FEATURE_NAME = 'Smart Tags';
+
/**
* Holds state for premium/non premium features for the current user features,
* and eventually for in-development features (feature flags).
@@ -19,23 +28,37 @@ export class FeaturesState {
window?._enable_unfinished_features;
_hasFolders = false;
+ _hasSmartTags = false;
+ _premiumAlertFeatureName: string | undefined;
+
private unsub: () => void;
constructor(private application: WebApplication) {
this._hasFolders = this.hasNativeFolders();
+ this._hasSmartTags = this.hasNativeSmartTags();
+ this._premiumAlertFeatureName = undefined;
makeObservable(this, {
_hasFolders: observable,
+ _hasSmartTags: observable,
hasFolders: computed,
enableNativeFoldersFeature: computed,
+ enableNativeSmartTagsFeature: computed,
+ _premiumAlertFeatureName: observable,
+ showPremiumAlert: action,
+ closePremiumAlert: action,
});
+ this.showPremiumAlert = this.showPremiumAlert.bind(this);
+ this.closePremiumAlert = this.closePremiumAlert.bind(this);
+
this.unsub = this.application.addEventObserver(async (eventName) => {
switch (eventName) {
case ApplicationEvent.FeaturesUpdated:
case ApplicationEvent.Launched:
runInAction(() => {
this._hasFolders = this.hasNativeFolders();
+ this._hasSmartTags = this.hasNativeSmartTags();
});
break;
default:
@@ -52,25 +75,25 @@ export class FeaturesState {
return this.enableUnfinishedFeatures;
}
+ public get enableNativeSmartTagsFeature(): boolean {
+ return this.enableUnfinishedFeatures;
+ }
+
public get hasFolders(): boolean {
return this._hasFolders;
}
- public set hasFolders(hasFolders: boolean) {
- if (!hasFolders) {
- this._hasFolders = false;
- return;
- }
+ public get hasSmartTags(): boolean {
+ return this._hasSmartTags;
+ }
- if (!this.hasNativeFolders()) {
- this.application.alertService?.alert(
- `${TAG_FOLDERS_FEATURE_NAME} requires at least a Plus Subscription.`
- );
- this._hasFolders = false;
- return;
- }
+ public async showPremiumAlert(featureName: string): Promise {
+ this._premiumAlertFeatureName = featureName;
+ return when(() => this._premiumAlertFeatureName === undefined);
+ }
- this._hasFolders = hasFolders;
+ public async closePremiumAlert(): Promise {
+ this._premiumAlertFeatureName = undefined;
}
private hasNativeFolders(): boolean {
@@ -84,4 +107,16 @@ export class FeaturesState {
return status === FeatureStatus.Entitled;
}
+
+ private hasNativeSmartTags(): boolean {
+ if (!this.enableNativeSmartTagsFeature) {
+ return false;
+ }
+
+ const status = this.application.getFeatureStatus(
+ FeatureIdentifier.SmartFilters
+ );
+
+ return status === FeatureStatus.Entitled;
+ }
}
diff --git a/app/assets/javascripts/ui_models/app_state/note_tags_state.ts b/app/assets/javascripts/ui_models/app_state/note_tags_state.ts
index f24a08936..1a99e5170 100644
--- a/app/assets/javascripts/ui_models/app_state/note_tags_state.ts
+++ b/app/assets/javascripts/ui_models/app_state/note_tags_state.ts
@@ -1,4 +1,4 @@
-import { SNNote, ContentType, SNTag, UuidString } from '@standardnotes/snjs';
+import { ContentType, SNNote, SNTag, UuidString } from '@standardnotes/snjs';
import { action, computed, makeObservable, observable } from 'mobx';
import { WebApplication } from '../application';
import { AppState } from './app_state';
@@ -194,4 +194,41 @@ export class NoteTagsState {
this.reloadTags();
}
}
+
+ getSortedTagsForNote(note: SNNote): SNTag[] {
+ const tags = this.application.getSortedTagsForNote(note);
+
+ const sortFunction = (tagA: SNTag, tagB: SNTag): number => {
+ const a = this.getLongTitle(tagA);
+ const b = this.getLongTitle(tagB);
+
+ if (a < b) {
+ return -1;
+ }
+ if (b > a) {
+ return 1;
+ }
+ return 0;
+ };
+
+ return tags.sort(sortFunction);
+ }
+
+ getPrefixTitle(tag: SNTag): string | undefined {
+ const hierarchy = this.application.getTagParentChain(tag);
+
+ if (hierarchy.length === 0) {
+ return undefined;
+ }
+
+ const prefixTitle = hierarchy.map((tag) => tag.title).join('/');
+ return `${prefixTitle}/`;
+ }
+
+ getLongTitle(tag: SNTag): string {
+ const hierarchy = this.application.getTagParentChain(tag);
+ const tags = [...hierarchy, tag];
+ const longTitle = tags.map((tag) => tag.title).join('/');
+ return longTitle;
+ }
}
diff --git a/app/assets/javascripts/ui_models/app_state/notes_state.ts b/app/assets/javascripts/ui_models/app_state/notes_state.ts
index 44dda28e1..d4ec3db1e 100644
--- a/app/assets/javascripts/ui_models/app_state/notes_state.ts
+++ b/app/assets/javascripts/ui_models/app_state/notes_state.ts
@@ -8,6 +8,7 @@ import {
ContentType,
SNTag,
ChallengeReason,
+ NoteViewController,
} from '@standardnotes/snjs';
import {
makeObservable,
@@ -17,7 +18,6 @@ import {
runInAction,
} from 'mobx';
import { WebApplication } from '../application';
-import { NoteViewController } from '@/views/note_view/note_view_controller';
import { AppState } from './app_state';
export class NotesState {
@@ -115,12 +115,12 @@ export class NotesState {
async selectNote(uuid: UuidString, userTriggered?: boolean): Promise {
const note = this.application.findItem(uuid) as SNNote;
+ const hasMeta = this.io.activeModifiers.has(KeyboardModifier.Meta);
+ const hasCtrl = this.io.activeModifiers.has(KeyboardModifier.Ctrl);
+ const hasShift = this.io.activeModifiers.has(KeyboardModifier.Shift);
+
if (note) {
- if (
- userTriggered &&
- (this.io.activeModifiers.has(KeyboardModifier.Meta) ||
- this.io.activeModifiers.has(KeyboardModifier.Ctrl))
- ) {
+ if (userTriggered && (hasMeta || hasCtrl)) {
if (this.selectedNotes[uuid]) {
delete this.selectedNotes[uuid];
} else if (await this.application.authorizeNoteAccess(note)) {
@@ -129,10 +129,7 @@ export class NotesState {
this.lastSelectedNote = note;
});
}
- } else if (
- userTriggered &&
- this.io.activeModifiers.has(KeyboardModifier.Shift)
- ) {
+ } else if (userTriggered && hasShift) {
await this.selectNotesRange(note);
} else {
const shouldSelectNote =
@@ -328,6 +325,7 @@ export class NotesState {
if (permanently) {
for (const note of Object.values(this.selectedNotes)) {
await this.application.deleteItem(note);
+ delete this.selectedNotes[note.uuid];
}
} else {
await this.changeSelectedNotes((mutator) => {
diff --git a/app/assets/javascripts/ui_models/app_state/notes_view_state.ts b/app/assets/javascripts/ui_models/app_state/notes_view_state.ts
index e0be2a0e0..319e6d07b 100644
--- a/app/assets/javascripts/ui_models/app_state/notes_view_state.ts
+++ b/app/assets/javascripts/ui_models/app_state/notes_view_state.ts
@@ -35,6 +35,7 @@ export type DisplayOptions = {
hideTags: boolean;
hideNotePreview: boolean;
hideDate: boolean;
+ hideEditorIcon: boolean;
};
export class NotesViewState {
@@ -58,6 +59,7 @@ export class NotesViewState {
hideTags: true,
hideDate: false,
hideNotePreview: false,
+ hideEditorIcon: false,
};
constructor(
@@ -301,6 +303,10 @@ export class NotesViewState {
PrefKey.NotesHideTags,
true
);
+ freshDisplayOptions.hideEditorIcon = this.application.getPreference(
+ PrefKey.NotesHideEditorIcon,
+ false
+ );
const displayOptionsChanged =
freshDisplayOptions.sortBy !== this.displayOptions.sortBy ||
freshDisplayOptions.sortReverse !== this.displayOptions.sortReverse ||
@@ -308,6 +314,8 @@ export class NotesViewState {
freshDisplayOptions.showArchived !== this.displayOptions.showArchived ||
freshDisplayOptions.showTrashed !== this.displayOptions.showTrashed ||
freshDisplayOptions.hideProtected !== this.displayOptions.hideProtected ||
+ freshDisplayOptions.hideEditorIcon !==
+ this.displayOptions.hideEditorIcon ||
freshDisplayOptions.hideTags !== this.displayOptions.hideTags;
this.displayOptions = freshDisplayOptions;
if (displayOptionsChanged) {
@@ -495,7 +503,9 @@ export class NotesViewState {
this.reloadNotesDisplayOptions();
this.reloadNotes();
- if (this.notes.length > 0) {
+ const hasSomeNotes = this.notes.length > 0;
+
+ if (hasSomeNotes) {
this.selectFirstNote();
} else if (dbLoaded) {
if (
diff --git a/app/assets/javascripts/ui_models/app_state/tags_state.ts b/app/assets/javascripts/ui_models/app_state/tags_state.ts
index 13e698d89..ba9325c78 100644
--- a/app/assets/javascripts/ui_models/app_state/tags_state.ts
+++ b/app/assets/javascripts/ui_models/app_state/tags_state.ts
@@ -1,7 +1,14 @@
+import { confirmDialog } from '@/services/alertService';
+import { STRING_DELETE_TAG, STRING_MISSING_SYSTEM_TAG } from '@/strings';
import {
+ ApplicationEvent,
+ ComponentAction,
ContentType,
+ MessageData,
+ SNApplication,
SNSmartTag,
SNTag,
+ TagMutator,
UuidString,
} from '@standardnotes/snjs';
import {
@@ -13,11 +20,60 @@ import {
runInAction,
} from 'mobx';
import { WebApplication } from '../application';
-import { FeaturesState } from './features_state';
+import { FeaturesState, SMART_TAGS_FEATURE_NAME } from './features_state';
+
+type AnyTag = SNTag | SNSmartTag;
+
+const rootTags = (application: SNApplication): SNTag[] => {
+ const hasNoParent = (tag: SNTag) => !application.getTagParent(tag);
+
+ const allTags = application.getDisplayableItems(ContentType.Tag) as SNTag[];
+ const rootTags = allTags.filter(hasNoParent);
+
+ return rootTags;
+};
+
+const tagSiblings = (application: SNApplication, tag: SNTag): SNTag[] => {
+ const withoutCurrentTag = (tags: SNTag[]) =>
+ tags.filter((other) => other.uuid !== tag.uuid);
+
+ const isTemplateTag = application.isTemplateItem(tag);
+ const parentTag = !isTemplateTag && application.getTagParent(tag);
+
+ if (parentTag) {
+ const siblingsAndTag = application.getTagChildren(parentTag);
+ return withoutCurrentTag(siblingsAndTag);
+ }
+
+ return withoutCurrentTag(rootTags(application));
+};
+
+const isValidFutureSiblings = (
+ application: SNApplication,
+ futureSiblings: SNTag[],
+ tag: SNTag
+): boolean => {
+ const siblingWithSameName = futureSiblings.find(
+ (otherTag) => otherTag.title === tag.title
+ );
+
+ if (siblingWithSameName) {
+ application.alertService?.alert(
+ `A tag with the name ${tag.title} already exists at this destination. Please rename this tag before moving and try again.`
+ );
+ return false;
+ }
+ return true;
+};
export class TagsState {
tags: SNTag[] = [];
smartTags: SNSmartTag[] = [];
+ allNotesCount_ = 0;
+ selected_: AnyTag | undefined;
+ previouslySelected_: AnyTag | undefined;
+ editing_: SNTag | undefined;
+
private readonly tagsCountsState: TagsCountsState;
constructor(
@@ -27,22 +83,43 @@ export class TagsState {
) {
this.tagsCountsState = new TagsCountsState(this.application);
+ this.selected_ = undefined;
+ this.previouslySelected_ = undefined;
+ this.editing_ = undefined;
+
+ this.smartTags = this.application.getSmartTags();
+ this.selected_ = this.smartTags[0];
+
makeObservable(this, {
tags: observable.ref,
smartTags: observable.ref,
- hasFolders: computed,
hasAtLeastOneFolder: computed,
+ allNotesCount_: observable,
+ allNotesCount: computed,
+
+ selected_: observable.ref,
+ previouslySelected_: observable.ref,
+ previouslySelected: computed,
+ editing_: observable.ref,
+ selected: computed,
+ selectedUuid: computed,
+ editingTag: computed,
assignParent: action,
rootTags: computed,
tagsCount: computed,
+
+ createNewTemplate: action,
+ undoCreateNewTag: action,
+ save: action,
+ remove: action,
});
appEventListeners.push(
this.application.streamItems(
[ContentType.Tag, ContentType.SmartTag],
- () => {
+ (items) => {
runInAction(() => {
this.tags = this.application.getDisplayableItems(
ContentType.Tag
@@ -50,10 +127,46 @@ export class TagsState {
this.smartTags = this.application.getSmartTags();
this.tagsCountsState.update(this.tags);
+ this.allNotesCount_ = this.countAllNotes();
+
+ const selectedTag = this.selected_;
+ if (selectedTag) {
+ const matchingTag = items.find(
+ (candidate) => candidate.uuid === selectedTag.uuid
+ );
+ if (matchingTag) {
+ if (matchingTag.deleted) {
+ this.selected_ = this.smartTags[0];
+ } else {
+ this.selected_ = matchingTag as AnyTag;
+ }
+ }
+ } else {
+ this.selected_ = this.smartTags[0];
+ }
});
}
)
);
+
+ appEventListeners.push(
+ this.application.addEventObserver(async (eventName) => {
+ switch (eventName) {
+ case ApplicationEvent.CompletedIncrementalSync:
+ runInAction(() => {
+ this.allNotesCount_ = this.countAllNotes();
+ });
+ break;
+ }
+ })
+ );
+ }
+
+ public get allLocalRootTags(): SNTag[] {
+ if (this.editing_ && this.application.isTemplateItem(this.editing_)) {
+ return [this.editing_, ...this.rootTags];
+ }
+ return this.rootTags;
}
public getNotesCount(tag: SNTag): number {
@@ -61,7 +174,7 @@ export class TagsState {
}
getChildren(tag: SNTag): SNTag[] {
- if (!this.hasFolders) {
+ if (!this.features.hasFolders) {
return [];
}
@@ -69,7 +182,10 @@ export class TagsState {
return [];
}
- const children = this.application.getTagChildren(tag);
+ const children = this.application
+ .getTagChildren(tag)
+ .filter((tag) => !tag.isSmartTag);
+
const childrenUuids = children.map((childTag) => childTag.uuid);
const childrenTags = this.tags.filter((tag) =>
childrenUuids.includes(tag.uuid)
@@ -87,12 +203,27 @@ export class TagsState {
): Promise {
const tag = this.application.findItem(tagUuid) as SNTag;
+ const currentParent = this.application.getTagParent(tag);
+ const currentParentUuid = currentParent?.parentId;
+
+ if (currentParentUuid === parentUuid) {
+ return;
+ }
+
const parent =
parentUuid && (this.application.findItem(parentUuid) as SNTag);
if (!parent) {
+ const futureSiblings = rootTags(this.application);
+ if (!isValidFutureSiblings(this.application, futureSiblings, tag)) {
+ return;
+ }
await this.application.unsetTagParent(tag);
} else {
+ const futureSiblings = this.application.getTagChildren(parent);
+ if (!isValidFutureSiblings(this.application, futureSiblings, tag)) {
+ return;
+ }
await this.application.setTagParent(parent, tag);
}
@@ -100,7 +231,7 @@ export class TagsState {
}
get rootTags(): SNTag[] {
- if (!this.hasFolders) {
+ if (!this.features.hasFolders) {
return this.tags;
}
@@ -111,12 +242,196 @@ export class TagsState {
return this.tags.length;
}
- public get hasFolders(): boolean {
- return this.features.hasFolders;
+ public get allNotesCount(): number {
+ return this.allNotesCount_;
}
- public set hasFolders(hasFolders: boolean) {
- this.features.hasFolders = hasFolders;
+ public get previouslySelected(): AnyTag | undefined {
+ return this.previouslySelected_;
+ }
+
+ public get selected(): AnyTag | undefined {
+ return this.selected_;
+ }
+
+ public set selected(tag: AnyTag | undefined) {
+ if (tag && tag.conflictOf) {
+ this.application.changeAndSaveItem(tag.uuid, (mutator) => {
+ mutator.conflictOf = undefined;
+ });
+ }
+
+ const selectionHasNotChanged = this.selected_?.uuid === tag?.uuid;
+
+ if (selectionHasNotChanged) {
+ return;
+ }
+
+ this.previouslySelected_ = this.selected_;
+ this.selected_ = tag;
+ }
+
+ public get selectedUuid(): UuidString | undefined {
+ return this.selected_?.uuid;
+ }
+
+ public get editingTag(): SNTag | undefined {
+ return this.editing_;
+ }
+
+ public set editingTag(editingTag: SNTag | undefined) {
+ this.editing_ = editingTag;
+ this.selected = editingTag;
+ }
+
+ public async createNewTemplate() {
+ const isAlreadyEditingATemplate =
+ this.editing_ && this.application.isTemplateItem(this.editing_);
+
+ if (isAlreadyEditingATemplate) {
+ return;
+ }
+
+ const newTag = (await this.application.createTemplateItem(
+ ContentType.Tag
+ )) as SNTag;
+
+ runInAction(() => {
+ this.editing_ = newTag;
+ });
+ }
+
+ public undoCreateNewTag() {
+ this.editing_ = undefined;
+ const previousTag = this.previouslySelected_ || this.smartTags[0];
+ this.selected = previousTag;
+ }
+
+ public async remove(tag: SNTag) {
+ if (
+ await confirmDialog({
+ text: STRING_DELETE_TAG,
+ confirmButtonStyle: 'danger',
+ })
+ ) {
+ this.application.deleteItem(tag);
+ this.selected = this.smartTags[0];
+ }
+ }
+
+ public async save(tag: SNTag, newTitle: string) {
+ const hasEmptyTitle = newTitle.length === 0;
+ const hasNotChangedTitle = newTitle === tag.title;
+ const isTemplateChange = this.application.isTemplateItem(tag);
+
+ const siblings = tagSiblings(this.application, tag);
+ const hasDuplicatedTitle = siblings.some(
+ (other) => other.title.toLowerCase() === newTitle.toLowerCase()
+ );
+
+ runInAction(() => {
+ this.editing_ = undefined;
+ });
+
+ if (hasEmptyTitle || hasNotChangedTitle) {
+ if (isTemplateChange) {
+ this.undoCreateNewTag();
+ }
+ return;
+ }
+
+ if (hasDuplicatedTitle) {
+ if (isTemplateChange) {
+ this.undoCreateNewTag();
+ }
+ this.application.alertService?.alert(
+ 'A tag with this name already exists.'
+ );
+ return;
+ }
+
+ if (isTemplateChange) {
+ if (this.features.enableNativeSmartTagsFeature) {
+ const isSmartTagTitle = this.application.isSmartTagTitle(newTitle);
+
+ if (isSmartTagTitle) {
+ if (!this.features.hasSmartTags) {
+ await this.features.showPremiumAlert(SMART_TAGS_FEATURE_NAME);
+ return;
+ }
+ }
+
+ const insertedTag = await this.application.createTagOrSmartTag(
+ newTitle
+ );
+ runInAction(() => {
+ this.selected = insertedTag as SNTag;
+ });
+ } else {
+ // Legacy code, remove me after we enableNativeSmartTagsFeature for everyone.
+ // See https://app.asana.com/0/0/1201612665552831/f
+ const insertedTag = await this.application.insertItem(tag);
+ const changedTag = await this.application.changeItem(
+ insertedTag.uuid,
+ (m) => {
+ m.title = newTitle;
+ }
+ );
+ this.selected = changedTag as SNTag;
+ await this.application.saveItem(insertedTag.uuid);
+ }
+ } else {
+ await this.application.changeAndSaveItem(
+ tag.uuid,
+ (mutator) => {
+ mutator.title = newTitle;
+ }
+ );
+ }
+ }
+
+ private countAllNotes(): number {
+ const allTag = this.application.getSmartTags().find((tag) => tag.isAllTag);
+
+ if (!allTag) {
+ console.error(STRING_MISSING_SYSTEM_TAG);
+ return -1;
+ }
+
+ const notes = this.application
+ .notesMatchingSmartTag(allTag)
+ .filter((note) => {
+ return !note.archived && !note.trashed;
+ });
+
+ return notes.length;
+ }
+
+ public onFoldersComponentMessage(
+ action: ComponentAction,
+ data: MessageData
+ ): void {
+ if (action === ComponentAction.SelectItem) {
+ const item = data.item;
+
+ if (!item) {
+ return;
+ }
+
+ if (
+ item.content_type === ContentType.Tag ||
+ item.content_type === ContentType.SmartTag
+ ) {
+ const matchingTag = this.application.findItem(item.uuid);
+
+ if (matchingTag) {
+ this.selected = matchingTag as AnyTag;
+ return;
+ }
+ }
+ } else if (action === ComponentAction.ClearSelection) {
+ this.selected = this.smartTags[0];
+ }
}
public get hasAtLeastOneFolder(): boolean {
diff --git a/app/assets/javascripts/ui_models/application.ts b/app/assets/javascripts/ui_models/application.ts
index c99fd8a9f..96c24ccc0 100644
--- a/app/assets/javascripts/ui_models/application.ts
+++ b/app/assets/javascripts/ui_models/application.ts
@@ -10,13 +10,13 @@ import { StatusManager } from '@/services/statusManager';
import { ThemeManager } from '@/services/themeManager';
import { PasswordWizardScope, PasswordWizardType } from '@/types';
import { AppState } from '@/ui_models/app_state';
-import { NoteGroupController } from '@/views/note_group_view/note_group_controller';
import { WebDeviceInterface } from '@/web_device_interface';
import {
DeinitSource,
PermissionDialog,
Platform,
SNApplication,
+ NoteGroupController,
} from '@standardnotes/snjs';
import angular from 'angular';
import { AccountSwitcherScope, PermissionsModalScope } from './../types';
diff --git a/app/assets/javascripts/ui_models/panel_resizer.ts b/app/assets/javascripts/ui_models/panel_resizer.ts
index 66ce0f9e9..c53b49c9d 100644
--- a/app/assets/javascripts/ui_models/panel_resizer.ts
+++ b/app/assets/javascripts/ui_models/panel_resizer.ts
@@ -56,6 +56,12 @@ export class PanelResizerState {
side,
widthEventCallback,
}: PanelResizerProps) {
+ const currentKnownPref =
+ (application.getPreference(prefKey) as number) ?? defaultWidth ?? 0;
+
+ this.panel = panel;
+ this.startLeft = this.panel.offsetLeft;
+ this.startWidth = this.panel.scrollWidth;
this.alwaysVisible = alwaysVisible ?? false;
this.application = application;
this.collapsable = collapsable ?? false;
@@ -66,16 +72,15 @@ export class PanelResizerState {
this.lastDownX = 0;
this.lastLeft = this.startLeft;
this.lastWidth = this.startWidth;
- this.panel = panel;
this.prefKey = prefKey;
this.pressed = false;
this.side = side;
- this.startLeft = this.panel.offsetLeft;
- this.startWidth = this.panel.scrollWidth;
this.widthBeforeLastDblClick = 0;
this.widthEventCallback = widthEventCallback;
this.resizeFinishCallback = resizeFinishCallback;
+ this.setWidth(currentKnownPref, true);
+
application.addEventObserver(async () => {
const changedWidth = application.getPreference(prefKey) as number;
if (changedWidth !== this.lastWidth) this.setWidth(changedWidth, true);
diff --git a/app/assets/javascripts/utils/index.ts b/app/assets/javascripts/utils/index.ts
index 4325f8370..b8588eb28 100644
--- a/app/assets/javascripts/utils/index.ts
+++ b/app/assets/javascripts/utils/index.ts
@@ -155,3 +155,12 @@ export function getDesktopVersion() {
export const isEmailValid = (email: string): boolean => {
return EMAIL_REGEX.test(email);
};
+
+export const openInNewTab = (url: string) => {
+ const newWindow = window.open(url, '_blank', 'noopener,noreferrer');
+ if (newWindow) newWindow.opener = null;
+};
+
+export const convertStringifiedBooleanToBoolean = (value: string) => {
+ return value !== 'false';
+};
diff --git a/app/assets/javascripts/views/application/application-view.pug b/app/assets/javascripts/views/application/application-view.pug
index 01630f2b5..58466fcf4 100644
--- a/app/assets/javascripts/views/application/application-view.pug
+++ b/app/assets/javascripts/views/application/application-view.pug
@@ -5,7 +5,7 @@
ng-class='self.state.appClass',
ng-if='!self.state.needsUnlock && self.state.launched'
)
- tags-view(application='self.application')
+ navigation(application='self.application', appState='self.appState')
notes-view(
application='self.application'
app-state='self.appState'
diff --git a/app/assets/javascripts/views/application/application_view.ts b/app/assets/javascripts/views/application/application_view.ts
index cc861404b..c2c41eede 100644
--- a/app/assets/javascripts/views/application/application_view.ts
+++ b/app/assets/javascripts/views/application/application_view.ts
@@ -8,7 +8,7 @@ import {
Challenge,
removeFromArray,
} from '@standardnotes/snjs';
-import { PANEL_NAME_NOTES, PANEL_NAME_TAGS } from '@/views/constants';
+import { PANEL_NAME_NOTES, PANEL_NAME_NAVIGATION } from '@/views/constants';
import { STRING_DEFAULT_FILE_ERROR } from '@/strings';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { alertDialog } from '@/services/alertService';
@@ -24,7 +24,7 @@ class ApplicationViewCtrl extends PureViewCtrl<
> {
public platformString: string;
private notesCollapsed = false;
- private tagsCollapsed = false;
+ private navigationCollapsed = false;
/**
* To prevent stale state reads (setState is async),
@@ -136,15 +136,15 @@ class ApplicationViewCtrl extends PureViewCtrl<
if (panel === PANEL_NAME_NOTES) {
this.notesCollapsed = collapsed;
}
- if (panel === PANEL_NAME_TAGS) {
- this.tagsCollapsed = collapsed;
+ if (panel === PANEL_NAME_NAVIGATION) {
+ this.navigationCollapsed = collapsed;
}
let appClass = '';
if (this.notesCollapsed) {
appClass += 'collapsed-notes';
}
- if (this.tagsCollapsed) {
- appClass += ' collapsed-tags';
+ if (this.navigationCollapsed) {
+ appClass += ' collapsed-navigation';
}
this.setState({ appClass });
} else if (eventName === AppStateEvent.WindowDidFocus) {
diff --git a/app/assets/javascripts/views/constants.ts b/app/assets/javascripts/views/constants.ts
index 57c83d9af..a84c79f91 100644
--- a/app/assets/javascripts/views/constants.ts
+++ b/app/assets/javascripts/views/constants.ts
@@ -1,4 +1,4 @@
export const PANEL_NAME_NOTES = 'notes';
-export const PANEL_NAME_TAGS = 'tags';
+export const PANEL_NAME_NAVIGATION = 'navigation';
export const EMAIL_REGEX =
/^([a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)$/;
diff --git a/app/assets/javascripts/views/index.ts b/app/assets/javascripts/views/index.ts
index 82223ae4c..81f8920df 100644
--- a/app/assets/javascripts/views/index.ts
+++ b/app/assets/javascripts/views/index.ts
@@ -4,5 +4,4 @@ export { ApplicationView } from './application/application_view';
export { NoteGroupViewDirective } from './note_group_view/note_group_view';
export { NoteViewDirective } from './note_view/note_view';
export { FooterView } from './footer/footer_view';
-export { TagsView } from './tags/tags_view';
export { ChallengeModal } from './challenge_modal/challenge_modal';
diff --git a/app/assets/javascripts/views/note_group_view/note_group_controller.ts b/app/assets/javascripts/views/note_group_view/note_group_controller.ts
deleted file mode 100644
index c625fc47c..000000000
--- a/app/assets/javascripts/views/note_group_view/note_group_controller.ts
+++ /dev/null
@@ -1,84 +0,0 @@
-import { removeFromArray, UuidString } from '@standardnotes/snjs';
-import { NoteViewController } from '@/views/note_view/note_view_controller';
-import { WebApplication } from '@/ui_models/application';
-
-type NoteControllerGroupChangeCallback = () => void;
-
-export class NoteGroupController {
- public noteControllers: NoteViewController[] = [];
- private application: WebApplication;
- changeObservers: NoteControllerGroupChangeCallback[] = [];
-
- constructor(application: WebApplication) {
- this.application = application;
- }
-
- public deinit() {
- (this.application as unknown) = undefined;
- for (const controller of this.noteControllers) {
- this.deleteNoteView(controller);
- }
- }
-
- async createNoteView(
- noteUuid?: string,
- noteTitle?: string,
- noteTag?: UuidString
- ) {
- const controller = new NoteViewController(
- this.application,
- noteUuid,
- noteTitle,
- noteTag
- );
- await controller.initialize();
- this.noteControllers.push(controller);
- this.notifyObservers();
- }
-
- deleteNoteView(controller: NoteViewController) {
- controller.deinit();
- removeFromArray(this.noteControllers, controller);
- }
-
- closeNoteView(controller: NoteViewController) {
- this.deleteNoteView(controller);
- this.notifyObservers();
- }
-
- closeActiveNoteView() {
- const activeController = this.activeNoteViewController;
- if (activeController) {
- this.deleteNoteView(activeController);
- }
- }
-
- closeAllNoteViews() {
- for (const controller of this.noteControllers) {
- this.deleteNoteView(controller);
- }
- }
-
- get activeNoteViewController() {
- return this.noteControllers[0];
- }
-
- /**
- * Notifies observer when the active controller has changed.
- */
- public addChangeObserver(callback: NoteControllerGroupChangeCallback) {
- this.changeObservers.push(callback);
- if (this.activeNoteViewController) {
- callback();
- }
- return () => {
- removeFromArray(this.changeObservers, callback);
- };
- }
-
- private notifyObservers() {
- for (const observer of this.changeObservers) {
- observer();
- }
- }
-}
diff --git a/app/assets/javascripts/views/note_group_view/note_group_view.ts b/app/assets/javascripts/views/note_group_view/note_group_view.ts
index 8d598d1aa..5a659017f 100644
--- a/app/assets/javascripts/views/note_group_view/note_group_view.ts
+++ b/app/assets/javascripts/views/note_group_view/note_group_view.ts
@@ -1,7 +1,7 @@
import { WebDirective } from './../../types';
import template from './note-group-view.pug';
-import { NoteViewController } from '@/views/note_view/note_view_controller';
import { PureViewCtrl } from '../abstract/pure_view_ctrl';
+import { NoteViewController } from '@standardnotes/snjs';
class NoteGroupView extends PureViewCtrl<
unknown,
@@ -20,9 +20,11 @@ class NoteGroupView extends PureViewCtrl<
}
$onInit() {
- this.application.noteControllerGroup.addChangeObserver(() => {
- this.controllers = this.application.noteControllerGroup.noteControllers;
- });
+ this.application.noteControllerGroup.addActiveControllerChangeObserver(
+ () => {
+ this.controllers = this.application.noteControllerGroup.noteControllers;
+ }
+ );
this.autorun(() => {
this.setState({
showMultipleSelectedNotes: this.appState.notes.selectedNotesCount > 1,
diff --git a/app/assets/javascripts/views/note_view/note_view.ts b/app/assets/javascripts/views/note_view/note_view.ts
index cd3857605..e1a2a22f6 100644
--- a/app/assets/javascripts/views/note_view/note_view.ts
+++ b/app/assets/javascripts/views/note_view/note_view.ts
@@ -1,5 +1,3 @@
-import { STRING_SAVING_WHILE_DOCUMENT_HIDDEN } from './../../strings';
-import { NoteViewController } from '@/views/note_view/note_view_controller';
import { WebApplication } from '@/ui_models/application';
import { PanelPuppet, WebDirective } from '@/types';
import angular from 'angular';
@@ -20,6 +18,7 @@ import {
TransactionalMutation,
ItemMutator,
ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction,
+ NoteViewController,
} from '@standardnotes/snjs';
import { debounce, isDesktopApplication } from '@/utils';
import { KeyboardModifier, KeyboardKey } from '@/services/ioService';
@@ -27,9 +26,6 @@ import template from './note-view.pug';
import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
import { EventSource } from '@/ui_models/app_state';
import {
- STRING_DELETED_NOTE,
- STRING_INVALID_NOTE,
- STRING_ELLIPSES,
STRING_DELETE_PLACEHOLDER_ATTEMPT,
STRING_DELETE_LOCKED_ATTEMPT,
STRING_EDIT_LOCKED_ATTEMPT,
@@ -37,10 +33,7 @@ import {
} from '@/strings';
import { confirmDialog } from '@/services/alertService';
-const NOTE_PREVIEW_CHAR_LIMIT = 80;
const MINIMUM_STATUS_DURATION = 400;
-const SAVE_TIMEOUT_DEBOUNCE = 350;
-const SAVE_TIMEOUT_NO_DEBOUNCE = 100;
const EDITOR_DEBOUNCE = 100;
const ElementIds = {
@@ -97,7 +90,6 @@ export class NoteView extends PureViewCtrl {
private leftPanelPuppet?: PanelPuppet;
private rightPanelPuppet?: PanelPuppet;
- private saveTimeout?: ng.IPromise;
private statusTimeout?: ng.IPromise;
private lastEditorFocusEventSource?: EventSource;
public editorValues: EditorValues = { title: '', text: '' };
@@ -108,6 +100,7 @@ export class NoteView extends PureViewCtrl {
private removeTabObserver?: () => void;
private removeComponentStreamObserver?: () => void;
private removeComponentManagerObserver?: () => void;
+ private removeInnerNoteObserver?: () => void;
private protectionTimeoutId: ReturnType | null = null;
@@ -139,6 +132,8 @@ export class NoteView extends PureViewCtrl {
deinit() {
this.removeComponentStreamObserver?.();
(this.removeComponentStreamObserver as unknown) = undefined;
+ this.removeInnerNoteObserver?.();
+ (this.removeInnerNoteObserver as unknown) = undefined;
this.removeComponentManagerObserver?.();
(this.removeComponentManagerObserver as unknown) = undefined;
this.removeTrashKeyObserver?.();
@@ -149,7 +144,6 @@ export class NoteView extends PureViewCtrl {
this.leftPanelPuppet = undefined;
this.rightPanelPuppet = undefined;
this.onEditorComponentLoad = undefined;
- this.saveTimeout = undefined;
this.statusTimeout = undefined;
(this.onPanelResizeFinish as unknown) = undefined;
(this.editorMenuOnSelect as unknown) = undefined;
@@ -167,9 +161,10 @@ export class NoteView extends PureViewCtrl {
$onInit() {
super.$onInit();
this.registerKeyboardShortcuts();
- this.controller.setOnNoteInnerValueChange((note, source) => {
- this.onNoteInnerChange(note, source);
- });
+ this.removeInnerNoteObserver =
+ this.controller.addNoteInnerValueChangeObserver((note, source) => {
+ this.onNoteInnerChange(note, source);
+ });
this.autorun(() => {
this.setState({
showProtectedWarning: this.appState.notes.showProtectedWarning,
@@ -479,13 +474,16 @@ export class NoteView extends PureViewCtrl {
const transactions: TransactionalMutation[] = [];
this.setMenuState('showEditorMenu', false);
+
if (this.appState.getActiveNoteController()?.isTemplateNote) {
await this.appState.getActiveNoteController().insertTemplatedNote();
}
+
if (this.note.locked) {
this.application.alertService.alert(STRING_EDIT_LOCKED_ATTEMPT);
return;
}
+
if (!component) {
if (!this.note.prefersPlainEditor) {
transactions.push({
@@ -542,83 +540,6 @@ export class NoteView extends PureViewCtrl {
);
}
- /**
- * @param bypassDebouncer Calling save will debounce by default. You can pass true to save
- * immediately.
- * @param isUserModified This field determines if the item will be saved as a user
- * modification, thus updating the user modified date displayed in the UI
- * @param dontUpdatePreviews Whether this change should update the note's plain and HTML
- * preview.
- * @param customMutate A custom mutator function.
- * @param closeAfterSync Whether this editor should be closed after the sync starts.
- * This allows us to make a destructive change, wait for sync to be triggered, then
- * close the editor (if we closed the editor before sync began, we'd get an exception,
- * since the debouncer will be triggered on a non-existent editor)
- */
- async save(
- note: SNNote,
- editorValues: EditorValues,
- bypassDebouncer = false,
- isUserModified = false,
- dontUpdatePreviews = false,
- customMutate?: (mutator: NoteMutator) => void,
- closeAfterSync = false
- ) {
- const title = editorValues.title;
- const text = editorValues.text;
- const isTemplate = this.controller.isTemplateNote;
- if (document.hidden) {
- this.application.alertService.alert(STRING_SAVING_WHILE_DOCUMENT_HIDDEN);
- return;
- }
- if (note.deleted) {
- this.application.alertService.alert(STRING_DELETED_NOTE);
- return;
- }
- if (isTemplate) {
- await this.controller.insertTemplatedNote();
- }
- if (!this.application.findItem(note.uuid)) {
- this.application.alertService.alert(STRING_INVALID_NOTE);
- return;
- }
- await this.application.changeItem(
- note.uuid,
- (mutator) => {
- const noteMutator = mutator as NoteMutator;
- if (customMutate) {
- customMutate(noteMutator);
- }
- noteMutator.title = title;
- noteMutator.text = text;
- if (!dontUpdatePreviews) {
- const noteText = text || '';
- const truncate = noteText.length > NOTE_PREVIEW_CHAR_LIMIT;
- const substring = noteText.substring(0, NOTE_PREVIEW_CHAR_LIMIT);
- const previewPlain = substring + (truncate ? STRING_ELLIPSES : '');
- // eslint-disable-next-line camelcase
- noteMutator.preview_plain = previewPlain;
- // eslint-disable-next-line camelcase
- noteMutator.preview_html = undefined;
- }
- },
- isUserModified
- );
- if (this.saveTimeout) {
- this.$timeout.cancel(this.saveTimeout);
- }
- const noDebounce = bypassDebouncer || this.application.noAccount();
- const syncDebouceMs = noDebounce
- ? SAVE_TIMEOUT_NO_DEBOUNCE
- : SAVE_TIMEOUT_DEBOUNCE;
- this.saveTimeout = this.$timeout(() => {
- this.application.sync();
- if (closeAfterSync) {
- this.appState.closeNoteController(this.controller);
- }
- }, syncDebouceMs);
- }
-
showSavingStatus() {
this.setStatus({ message: 'Saving…' }, false);
}
@@ -672,7 +593,10 @@ export class NoteView extends PureViewCtrl {
}
contentChanged() {
- this.save(this.note, copyEditorValues(this.editorValues), false, true);
+ this.controller.save({
+ editorValues: copyEditorValues(this.editorValues),
+ isUserModified: true,
+ });
}
onTitleEnter($event: Event) {
@@ -682,13 +606,11 @@ export class NoteView extends PureViewCtrl {
}
onTitleChange() {
- this.save(
- this.note,
- copyEditorValues(this.editorValues),
- false,
- true,
- true
- );
+ this.controller.save({
+ editorValues: copyEditorValues(this.editorValues),
+ isUserModified: true,
+ dontUpdatePreviews: true,
+ });
}
focusEditor() {
@@ -740,16 +662,14 @@ export class NoteView extends PureViewCtrl {
if (permanently) {
this.performNoteDeletion(this.note);
} else {
- this.save(
- this.note,
- copyEditorValues(this.editorValues),
- true,
- false,
- true,
- (mutator) => {
+ this.controller.save({
+ editorValues: copyEditorValues(this.editorValues),
+ bypassDebouncer: true,
+ dontUpdatePreviews: true,
+ customMutate: (mutator) => {
mutator.trashed = true;
- }
- );
+ },
+ });
}
}
}
@@ -1018,7 +938,11 @@ export class NoteView extends PureViewCtrl {
editor.selectionStart = editor.selectionEnd = start + 4;
}
this.editorValues.text = editor.value;
- this.save(this.note, copyEditorValues(this.editorValues), true);
+
+ this.controller.save({
+ editorValues: copyEditorValues(this.editorValues),
+ bypassDebouncer: true,
+ });
},
});
diff --git a/app/assets/javascripts/views/note_view/note_view_controller.ts b/app/assets/javascripts/views/note_view/note_view_controller.ts
deleted file mode 100644
index 5b6a81d14..000000000
--- a/app/assets/javascripts/views/note_view/note_view_controller.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import {
- SNNote,
- ContentType,
- PayloadSource,
- UuidString,
- SNTag,
-} from '@standardnotes/snjs';
-import { WebApplication } from '@/ui_models/application';
-
-export class NoteViewController {
- public note!: SNNote;
- private application: WebApplication;
- private onNoteValueChange?: (note: SNNote, source: PayloadSource) => void;
- private removeStreamObserver?: () => void;
- public isTemplateNote = false;
-
- constructor(
- application: WebApplication,
- noteUuid: string | undefined,
- private defaultTitle: string | undefined,
- private defaultTag: UuidString | undefined
- ) {
- this.application = application;
- if (noteUuid) {
- this.note = application.findItem(noteUuid) as SNNote;
- }
- }
-
- async initialize(): Promise {
- if (!this.note) {
- const note = (await this.application.createTemplateItem(
- ContentType.Note,
- {
- text: '',
- title: this.defaultTitle,
- references: [],
- }
- )) as SNNote;
- if (this.defaultTag) {
- const tag = this.application.findItem(this.defaultTag) as SNTag;
- await this.application.addTagHierarchyToNote(note, tag);
- }
- this.isTemplateNote = true;
- this.note = note;
- this.onNoteValueChange?.(this.note, this.note.payload.source);
- }
- this.streamItems();
- }
-
- private streamItems() {
- this.removeStreamObserver = this.application.streamItems(
- ContentType.Note,
- (items, source) => {
- this.handleNoteStream(items as SNNote[], source);
- }
- );
- }
-
- deinit() {
- this.removeStreamObserver?.();
- (this.removeStreamObserver as unknown) = undefined;
- (this.application as unknown) = undefined;
- this.onNoteValueChange = undefined;
- }
-
- private handleNoteStream(notes: SNNote[], source: PayloadSource) {
- /** Update our note object reference whenever it changes */
- const matchingNote = notes.find((item) => {
- return item.uuid === this.note.uuid;
- }) as SNNote;
- if (matchingNote) {
- this.isTemplateNote = false;
- this.note = matchingNote;
- this.onNoteValueChange?.(matchingNote, source);
- }
- }
-
- insertTemplatedNote() {
- this.isTemplateNote = false;
- return this.application.insertItem(this.note);
- }
-
- /**
- * Register to be notified when the controller's note's inner values change
- * (and thus a new object reference is created)
- */
- public setOnNoteInnerValueChange(
- callback: (note: SNNote, source: PayloadSource) => void
- ) {
- this.onNoteValueChange = callback;
- if (this.note) {
- this.onNoteValueChange(this.note, this.note.payload.source);
- }
- }
-}
diff --git a/app/assets/javascripts/views/tags/tags-view.pug b/app/assets/javascripts/views/tags/tags-view.pug
deleted file mode 100644
index 4256b0181..000000000
--- a/app/assets/javascripts/views/tags/tags-view.pug
+++ /dev/null
@@ -1,43 +0,0 @@
-#tags-column.sn-component.section.tags(aria-label='Tags')
- .component-view-container(ng-if='self.state.componentViewer')
- component-view.component-view(
- component-viewer='self.state.componentViewer',
- application='self.application'
- app-state='self.appState'
- )
- #tags-content.content(ng-if='!(self.state.componentViewer)')
- .tags-title-section.section-title-bar
- .section-title-bar-header
- .sk-h3.title
- span.sk-bold Views
- .sk-button.sk-secondary-contrast.wide(
- ng-click='self.clickedAddNewTag()',
- title='Create a new tag'
- )
- .sk-label
- i.icon.ion-plus.add-button
- .scrollable
- .infinite-scroll
- .tag(
- ng-class="{'selected' : self.state.selectedTag == tag, 'faded' : !tag.isAllTag}",
- ng-click='self.selectTag(tag)',
- ng-repeat='tag in self.state.smartTags track by tag.uuid'
- )
- .tag-info
- .title(ng-if="!tag.errorDecrypting") {{tag.title}}
- .count(ng-show='tag.isAllTag') {{self.state.noteCounts[tag.uuid]}}
- .danger.small-text.font-bold(ng-show='tag.conflictOf') Conflicted Copy
- .danger.small-text.font-bold(ng-show='tag.errorDecrypting && !tag.waitingForKey') Missing Keys
- .info.small-text.font-bold(ng-show='tag.errorDecrypting && tag.waitingForKey') Waiting For Keys
- tags-section(
- application='self.application',
- app-state='self.appState'
- )
- panel-resizer(
- collapsable='true',
- control='self.panelPuppet',
- default-width='150',
- hoverable='true',
- on-resize-finish='self.onPanelResize',
- panel-id="'tags-column'"
- )
diff --git a/app/assets/javascripts/views/tags/tags_view.ts b/app/assets/javascripts/views/tags/tags_view.ts
deleted file mode 100644
index 69b94db50..000000000
--- a/app/assets/javascripts/views/tags/tags_view.ts
+++ /dev/null
@@ -1,298 +0,0 @@
-import { PanelPuppet, WebDirective } from '@/types';
-import { WebApplication } from '@/ui_models/application';
-import { AppStateEvent } from '@/ui_models/app_state';
-import { PANEL_NAME_TAGS } from '@/views/constants';
-import {
- ApplicationEvent,
- ComponentAction,
- ComponentArea,
- ComponentViewer,
- ContentType,
- isPayloadSourceInternalChange,
- MessageData,
- PayloadSource,
- PrefKey,
- SNComponent,
- SNSmartTag,
- SNTag,
- UuidString,
-} from '@standardnotes/snjs';
-import { PureViewCtrl } from '@Views/abstract/pure_view_ctrl';
-import template from './tags-view.pug';
-
-type NoteCounts = Partial>;
-
-type TagState = {
- smartTags: SNSmartTag[];
- noteCounts: NoteCounts;
- selectedTag?: SNTag;
- componentViewer?: ComponentViewer;
-};
-
-class TagsViewCtrl extends PureViewCtrl {
- /** Passed through template */
- readonly application!: WebApplication;
- private readonly panelPuppet: PanelPuppet;
- private unregisterComponent?: () => void;
- /** The original name of the edtingTag before it began editing */
- formData: { tagTitle?: string } = {};
- titles: Partial> = {};
- private removeTagsObserver!: () => void;
- private removeFoldersObserver!: () => void;
-
- /* @ngInject */
- constructor($timeout: ng.ITimeoutService) {
- super($timeout);
- this.panelPuppet = {
- onReady: () => this.loadPreferences(),
- };
- }
-
- deinit() {
- this.removeTagsObserver?.();
- (this.removeTagsObserver as unknown) = undefined;
- (this.removeFoldersObserver as unknown) = undefined;
- this.unregisterComponent?.();
- this.unregisterComponent = undefined;
- super.deinit();
- }
-
- getInitialState(): TagState {
- return {
- smartTags: [],
- noteCounts: {},
- };
- }
-
- getState(): TagState {
- return this.state;
- }
-
- async onAppLaunch() {
- super.onAppLaunch();
- this.loadPreferences();
- this.streamForFoldersComponent();
-
- const smartTags = this.application.getSmartTags();
- this.setState({ smartTags });
- this.selectTag(smartTags[0]);
- }
-
- /** @override */
- onAppIncrementalSync() {
- super.onAppIncrementalSync();
- this.reloadNoteCounts();
- }
-
- async setFoldersComponent(component?: SNComponent) {
- if (this.state.componentViewer) {
- this.application.componentManager.destroyComponentViewer(
- this.state.componentViewer
- );
- await this.setState({ componentViewer: undefined });
- }
- if (component) {
- await this.setState({
- componentViewer:
- this.application.componentManager.createComponentViewer(
- component,
- undefined,
- this.handleFoldersComponentMessage.bind(this)
- ),
- });
- }
- }
-
- handleFoldersComponentMessage(
- action: ComponentAction,
- data: MessageData
- ): void {
- if (action === ComponentAction.SelectItem) {
- const item = data.item;
- if (!item) {
- return;
- }
-
- if (item.content_type === ContentType.Tag) {
- const matchingTag = this.application.findItem(item.uuid);
-
- if (matchingTag) {
- this.selectTag(matchingTag as SNTag);
- }
- } else if (item.content_type === ContentType.SmartTag) {
- const matchingTag = this.getState().smartTags.find(
- (t) => t.uuid === item.uuid
- );
-
- if (matchingTag) {
- this.selectTag(matchingTag);
- }
- }
- } else if (action === ComponentAction.ClearSelection) {
- this.selectTag(this.getState().smartTags[0]);
- }
- }
-
- streamForFoldersComponent() {
- this.removeFoldersObserver = this.application.streamItems(
- [ContentType.Component],
- async (items, source) => {
- if (
- isPayloadSourceInternalChange(source) ||
- source === PayloadSource.InitialObserverRegistrationPush
- ) {
- return;
- }
- const components = items as SNComponent[];
- const hasFoldersChange = !!components.find(
- (component) => component.area === ComponentArea.TagsList
- );
- if (hasFoldersChange) {
- this.setFoldersComponent(
- this.application.componentManager
- .componentsForArea(ComponentArea.TagsList)
- .find((component) => component.active)
- );
- }
- }
- );
-
- this.removeTagsObserver = this.application.streamItems(
- [ContentType.Tag, ContentType.SmartTag],
- async (items) => {
- const tags = items as Array;
-
- await this.setState({
- smartTags: this.application.getSmartTags(),
- });
-
- for (const tag of tags) {
- this.titles[tag.uuid] = tag.title;
- }
-
- this.reloadNoteCounts();
- const selectedTag = this.state.selectedTag;
-
- if (selectedTag) {
- /** If the selected tag has been deleted, revert to All view. */
- const matchingTag = tags.find((tag) => {
- return tag.uuid === selectedTag.uuid;
- });
-
- if (matchingTag) {
- if (matchingTag.deleted) {
- this.selectTag(this.getState().smartTags[0]);
- } else {
- this.setState({
- selectedTag: matchingTag,
- });
- }
- }
- }
- }
- );
- }
-
- /** @override */
- onAppStateEvent(eventName: AppStateEvent) {
- if (eventName === AppStateEvent.TagChanged) {
- this.setState({
- selectedTag: this.application.getAppState().getSelectedTag(),
- });
- }
- }
-
- /** @override */
- async onAppEvent(eventName: ApplicationEvent) {
- super.onAppEvent(eventName);
- switch (eventName) {
- case ApplicationEvent.LocalDataIncrementalLoad:
- this.reloadNoteCounts();
- break;
- case ApplicationEvent.PreferencesChanged:
- this.loadPreferences();
- break;
- }
- }
-
- reloadNoteCounts() {
- const smartTags = this.state.smartTags;
- const noteCounts: NoteCounts = {};
-
- for (const tag of smartTags) {
- /** Other smart tags do not contain counts */
- if (tag.isAllTag) {
- const notes = this.application
- .notesMatchingSmartTag(tag as SNSmartTag)
- .filter((note) => {
- return !note.archived && !note.trashed;
- });
- noteCounts[tag.uuid] = notes.length;
- }
- }
-
- this.setState({
- noteCounts: noteCounts,
- });
- }
-
- loadPreferences() {
- if (!this.panelPuppet.ready) {
- return;
- }
-
- const width = this.application.getPreference(PrefKey.TagsPanelWidth);
- if (width) {
- this.panelPuppet.setWidth!(width);
- if (this.panelPuppet.isCollapsed!()) {
- this.application
- .getAppState()
- .panelDidResize(PANEL_NAME_TAGS, this.panelPuppet.isCollapsed!());
- }
- }
- }
-
- onPanelResize = (
- newWidth: number,
- _lastLeft: number,
- _isAtMaxWidth: boolean,
- isCollapsed: boolean
- ) => {
- this.application
- .setPreference(PrefKey.TagsPanelWidth, newWidth)
- .then(() => this.application.sync());
- this.application.getAppState().panelDidResize(PANEL_NAME_TAGS, isCollapsed);
- };
-
- async selectTag(tag: SNTag) {
- if (tag.conflictOf) {
- this.application.changeAndSaveItem(tag.uuid, (mutator) => {
- mutator.conflictOf = undefined;
- });
- }
- this.application.getAppState().setSelectedTag(tag);
- }
-
- async clickedAddNewTag() {
- if (this.appState.templateTag) {
- return;
- }
-
- this.appState.createNewTag();
- }
-}
-
-export class TagsView extends WebDirective {
- constructor() {
- super();
- this.restrict = 'E';
- this.scope = {
- application: '=',
- };
- this.template = template;
- this.replace = true;
- this.controller = TagsViewCtrl;
- this.controllerAs = 'self';
- this.bindToController = true;
- }
-}
diff --git a/app/assets/stylesheets/_focused.scss b/app/assets/stylesheets/_focused.scss
index d16d4866a..d6e27c824 100644
--- a/app/assets/stylesheets/_focused.scss
+++ b/app/assets/stylesheets/_focused.scss
@@ -35,7 +35,7 @@
opacity: 1;
}
- .section.tags,
+ navigation,
notes-view {
will-change: opacity;
animation: fade-out 1.25s forwards;
@@ -45,7 +45,7 @@
flex: none !important;
}
- .section.tags:hover {
+ navigation:hover {
flex: initial;
width: 0px !important;
}
@@ -57,7 +57,7 @@
}
.disable-focus-mode {
- .section.tags,
+ navigation,
notes-view {
transition: width 1.25s;
will-change: opacity;
diff --git a/app/assets/stylesheets/_main.scss b/app/assets/stylesheets/_main.scss
index 635816c3e..523409136 100644
--- a/app/assets/stylesheets/_main.scss
+++ b/app/assets/stylesheets/_main.scss
@@ -203,11 +203,6 @@ $footer-height: 2rem;
position: relative;
overflow: hidden;
- .scrollable {
- overflow-y: auto;
- overflow-x: hidden;
- }
-
> .content {
height: 100%;
max-height: 100%;
@@ -250,3 +245,21 @@ $footer-height: 2rem;
.z-index-purchase-flow {
z-index: $z-index-purchase-flow;
}
+
+textarea {
+ &.non-interactive {
+ user-select: text !important;
+ resize: none;
+ background-color: transparent;
+ border-color: var(--sn-stylekit-border-color);
+ font-family: monospace;
+ outline: 0;
+
+ -webkit-user-select: none;
+ -webkit-touch-callout: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ -o-user-select: none;
+ }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/_tags.scss b/app/assets/stylesheets/_navigation.scss
similarity index 87%
rename from app/assets/stylesheets/_tags.scss
rename to app/assets/stylesheets/_navigation.scss
index e44610a44..7faf812ca 100644
--- a/app/assets/stylesheets/_tags.scss
+++ b/app/assets/stylesheets/_navigation.scss
@@ -1,5 +1,12 @@
-.tags {
- width: 180px;
+@import './scrollbar';
+
+#navigation .scrollable {
+ @include minimal_scrollbar();
+ height: 100%;
+}
+
+#navigation {
+ width: 100%;
flex-grow: 0;
user-select: none;
@@ -8,42 +15,21 @@
-webkit-user-select: none;
&,
- #tags-content {
- background-color: var(--sn-stylekit-secondary-background-color);
+ #navigation-content {
display: flex;
flex-direction: column;
+ background-color: var(--sn-stylekit-secondary-background-color);
}
- .tags-title-section {
+ .section-title-bar {
color: var(--sn-stylekit-secondary-foreground-color);
padding-top: 15px;
padding-bottom: 8px;
- padding-left: 12px;
- padding-right: 12px;
+ padding-left: 14px;
+ padding-right: 14px;
font-size: 12px;
}
- .scrollable {
- height: 100%;
- }
-
- .infinite-scroll {
- overflow-x: hidden;
- height: inherit;
-
- // Autohide scrollbar on Windows.
- @at-root {
- .windows-web &,
- .windows-desktop & {
- overflow-y: hidden;
- &:hover {
- overflow-y: auto;
- overflow-y: overlay; // overlay is not supported on ff, so keep previous statement up
- }
- }
- }
- }
-
.no-tags-placeholder {
padding: 0px 12px;
font-size: 12px;
@@ -76,12 +62,13 @@
}
}
- .tag {
+ .tag,
+ .root-drop {
font-size: 14px;
line-height: 18px;
min-height: 30px;
- padding: 5px 12px;
+ padding: 5px 14px;
cursor: pointer;
transition: height 0.1s ease-in-out;
position: relative;
diff --git a/app/assets/stylesheets/_notes.scss b/app/assets/stylesheets/_notes.scss
index 6ec937804..06f66c262 100644
--- a/app/assets/stylesheets/_notes.scss
+++ b/app/assets/stylesheets/_notes.scss
@@ -1,3 +1,5 @@
+@import './scrollbar';
+
notes-view {
width: 350px;
}
@@ -101,64 +103,106 @@ notes-view {
}
}
- .scrollable {
- height: 100%;
- }
-
.infinite-scroll {
- overflow-x: hidden;
+ @include minimal_scrollbar();
height: inherit;
-
- // Autohide scrollbar on Windows.
- @at-root {
- .windows-web &,
- .windows-desktop & {
- overflow-y: hidden;
- &:hover {
- overflow-y: auto;
- overflow-y: overlay; // overlay is not supported on ff, so keep previous statement up
- }
- }
- }
+ background-color: var(--sn-stylekit-background-color);
}
.note {
+ display: flex;
+ align-items: stretch;
+
width: 100%;
- padding: 15px;
- border-bottom: 1px solid var(--sn-stylekit-border-color);
cursor: pointer;
- > .name {
- font-weight: 600;
- overflow: hidden;
- text-overflow: ellipsis;
+ &:hover {
+ background-color: var(--sn-stylekit-grey-5);
}
- > .bottom-info {
- font-size: 12px;
- margin-top: 4px;
+ .icon {
+ display: flex;
+ flex-flow: column;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem;
+ padding-right: 0.75rem;
+ margin-right: 0;
+ }
+
+ .meta {
+ flex-grow: 1;
+ min-width: 0;
+ padding: 1rem;
+ padding-left: 0;
+ border-bottom: 1px solid var(--sn-stylekit-border-color);
+
+ &.icon-hidden {
+ padding-left: 1rem;
+ }
+
+ .name {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-weight: 600;
+ font-size: 1rem;
+ line-height: 1.3;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .flag-icons {
+ &,
+ & > * {
+ display: flex;
+ align-items: center;
+ }
+
+ & > * + * {
+ margin-left: 0.375rem;
+ }
+ }
+
+ .bottom-info {
+ font-size: 12px;
+ line-height: 1.4;
+ margin-top: 0.25rem;
+ }
}
.tags-string {
- margin-top: 4px;
- font-size: 12px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 0.345rem;
+ font-size: 0.725rem;
+
+ .tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.25rem 0.375rem 0.25rem 0.325rem;
+ background-color: var(--sn-stylekit-grey-4-opacity-variant);
+ border-radius: 0.125rem;
+ }
}
.note-preview {
font-size: var(--sn-stylekit-font-size-h3);
- margin-top: 2px;
-
overflow: hidden;
text-overflow: ellipsis;
+ & > * {
+ margin-top: 0.15rem;
+ }
+
.default-preview,
.plain-preview {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1; /* number of lines to show */
- $line-height: 18px;
- line-height: $line-height; /* fallback */
- max-height: calc(#{$line-height} * 1); /* fallback */
+ line-height: 1.3;
+ overflow: hidden;
}
.html-preview {
@@ -175,8 +219,7 @@ notes-view {
display: flex;
flex-direction: row;
align-items: center;
- margin-bottom: 8px;
- margin-top: -4px;
+ margin-top: 0.125rem;
.flag {
padding: 4px;
@@ -238,13 +281,8 @@ notes-view {
}
&.selected {
- background-color: var(--sn-stylekit-info-color);
- color: var(--sn-stylekit-info-contrast-color);
-
- .note-flags .flag {
- background-color: var(--sn-stylekit-info-contrast-color);
- color: var(--sn-stylekit-info-color);
- }
+ background-color: var(--sn-stylekit-grey-5);
+ border-left: 2px solid var(--sn-stylekit-info-color);
progress {
background-color: var(--sn-stylekit-secondary-foreground-color);
@@ -255,7 +293,7 @@ notes-view {
}
&::-webkit-progress-value {
- background-color: var(--sn-stylekit-secondary-background-color);
+ background-color: var(--sn-stylekit-info-color);
}
&::-moz-progress-bar {
diff --git a/app/assets/stylesheets/_scrollbar.scss b/app/assets/stylesheets/_scrollbar.scss
new file mode 100644
index 000000000..3c5bb8bc3
--- /dev/null
+++ b/app/assets/stylesheets/_scrollbar.scss
@@ -0,0 +1,9 @@
+@mixin minimal_scrollbar() {
+ overflow-x: hidden;
+ overflow-y: hidden;
+
+ &:hover {
+ overflow-y: auto;
+ overflow-y: overlay;
+ }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/_sn.scss b/app/assets/stylesheets/_sn.scss
index 5ba5161c0..3cbcf3f1a 100644
--- a/app/assets/stylesheets/_sn.scss
+++ b/app/assets/stylesheets/_sn.scss
@@ -40,6 +40,11 @@
@extend .h-3\.5;
@extend .w-3\.5;
}
+
+ &.sn-icon--mid {
+ @extend .w-4;
+ @extend .h-4;
+ }
}
.sn-dropdown {
@@ -777,6 +782,7 @@
}
&:hover {
+ background-color: var(--sn-stylekit-contrast-background-color) !important;
@extend .color-info;
@extend .border-info;
}
@@ -807,3 +813,9 @@
}
}
}
+
+.dimmed {
+ opacity: .5;
+ cursor: default;
+ pointer-events: none;
+}
diff --git a/app/assets/stylesheets/_ui.scss b/app/assets/stylesheets/_ui.scss
index 563e9d77c..c2e8fab59 100644
--- a/app/assets/stylesheets/_ui.scss
+++ b/app/assets/stylesheets/_ui.scss
@@ -206,6 +206,10 @@ $screen-md-max: ($screen-lg-min - 1) !default;
cursor: default;
}
+.pointer-events-none {
+ pointer-events: none;
+}
+
.fill-current {
fill: currentColor;
}
diff --git a/app/assets/stylesheets/index.css.scss b/app/assets/stylesheets/index.css.scss
index 330d1cb19..b138f89bc 100644
--- a/app/assets/stylesheets/index.css.scss
+++ b/app/assets/stylesheets/index.css.scss
@@ -2,7 +2,7 @@
@import 'main';
@import 'ui';
@import 'footer';
-@import 'tags';
+@import 'navigation';
@import 'notes';
@import 'editor';
@import 'menus';
diff --git a/index.html b/index.html
index 27a830b10..c86451a72 100644
--- a/index.html
+++ b/index.html
@@ -36,7 +36,10 @@
data-purchase-url="<%= env.PURCHASE_URL %>"
data-plans-url="<%= env.PLANS_URL %>"
data-dashboard-url="<%= env.DASHBOARD_URL %>"
->
+ data-dev-account-email="<%= env.DEV_ACCOUNT_EMAIL %>"
+ data-dev-account-password="<%= env.DEV_ACCOUNT_PASSWORD %>"
+ data-dev-account-server="<%= env.DEV_ACCOUNT_SERVER %>"
+ >