diff --git a/.env.sample b/.env.sample index 9909770d6..195dd62c9 100644 --- a/.env.sample +++ b/.env.sample @@ -24,3 +24,7 @@ NEW_RELIC_THREAD_PROFILER_ENABLED=false NEW_RELIC_LICENSE_KEY= NEW_RELIC_APP_NAME=Web NEW_RELIC_BROWSER_MONITORING_AUTO_INSTRUMENT=false + +DEV_ACCOUNT_EMAIL= +DEV_ACCOUNT_PASSWORD= +DEV_ACCOUNT_SERVER= diff --git a/app/assets/icons/ic-add.svg b/app/assets/icons/ic-add.svg index d92c119a2..b785d2267 100644 --- a/app/assets/icons/ic-add.svg +++ b/app/assets/icons/ic-add.svg @@ -1,3 +1,3 @@ - + diff --git a/app/assets/icons/ic-authenticator.svg b/app/assets/icons/ic-authenticator.svg index 9a1193919..e8dd720cf 100644 --- a/app/assets/icons/ic-authenticator.svg +++ b/app/assets/icons/ic-authenticator.svg @@ -1,3 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/assets/icons/ic-code.svg b/app/assets/icons/ic-code.svg index 4a871e270..79df4be8d 100644 --- a/app/assets/icons/ic-code.svg +++ b/app/assets/icons/ic-code.svg @@ -1,3 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/assets/icons/ic-lock-filled.svg b/app/assets/icons/ic-lock-filled.svg new file mode 100644 index 000000000..a71db2794 --- /dev/null +++ b/app/assets/icons/ic-lock-filled.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/app/assets/icons/ic-markdown.svg b/app/assets/icons/ic-markdown.svg index bceed54b3..1efac5876 100644 --- a/app/assets/icons/ic-markdown.svg +++ b/app/assets/icons/ic-markdown.svg @@ -1,3 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/assets/icons/ic-notes.svg b/app/assets/icons/ic-notes.svg new file mode 100644 index 000000000..ece661333 --- /dev/null +++ b/app/assets/icons/ic-notes.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/icons/ic-pin-filled.svg b/app/assets/icons/ic-pin-filled.svg new file mode 100644 index 000000000..4e5ae92a5 --- /dev/null +++ b/app/assets/icons/ic-pin-filled.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/app/assets/icons/ic-spreadsheets.svg b/app/assets/icons/ic-spreadsheets.svg index 70f175be2..2566d69bb 100644 --- a/app/assets/icons/ic-spreadsheets.svg +++ b/app/assets/icons/ic-spreadsheets.svg @@ -1,3 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/assets/icons/ic-tasks.svg b/app/assets/icons/ic-tasks.svg index 0f8ef0587..c6b89554f 100644 --- a/app/assets/icons/ic-tasks.svg +++ b/app/assets/icons/ic-tasks.svg @@ -1,3 +1,4 @@ - - - + + + \ No newline at end of file diff --git a/app/assets/icons/ic-text-paragraph.svg b/app/assets/icons/ic-text-paragraph.svg index 4f43cdc0c..376e8ad46 100644 --- a/app/assets/icons/ic-text-paragraph.svg +++ b/app/assets/icons/ic-text-paragraph.svg @@ -1,4 +1,3 @@ - - - - \ No newline at end of file + + + \ No newline at end of file diff --git a/app/assets/icons/ic-text-rich.svg b/app/assets/icons/ic-text-rich.svg index 87f57dd41..d895ca8c4 100644 --- a/app/assets/icons/ic-text-rich.svg +++ b/app/assets/icons/ic-text-rich.svg @@ -1,3 +1,4 @@ - - + + \ No newline at end of file diff --git a/app/assets/icons/ic-trash-filled.svg b/app/assets/icons/ic-trash-filled.svg new file mode 100644 index 000000000..63f9575bf --- /dev/null +++ b/app/assets/icons/ic-trash-filled.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/app/assets/javascripts/app.ts b/app/assets/javascripts/app.ts index 2d1762e8d..49fcb298c 100644 --- a/app/assets/javascripts/app.ts +++ b/app/assets/javascripts/app.ts @@ -17,78 +17,74 @@ declare global { // eslint-disable-next-line camelcase _websocket_url: string; startApplication?: StartApplication; + + _devAccountEmail?: string; + _devAccountPassword?: string; + _devAccountServer?: string; } } -import { SNLog } from '@standardnotes/snjs'; -import angular from 'angular'; -import { configRoutes } from './routes'; - -import { ApplicationGroup } from './ui_models/application_group'; -import { AccountSwitcher } from './views/account_switcher/account_switcher'; - +import { ComponentViewDirective } from '@/components/ComponentView'; +import { NavigationDirective } from '@/components/Navigation'; +import { PinNoteButtonDirective } from '@/components/PinNoteButton'; +import { IsWebPlatform, WebAppVersion } from '@/version'; import { ApplicationGroupView, ApplicationView, + ChallengeModal, + FooterView, NoteGroupViewDirective, NoteViewDirective, - TagsView, - FooterView, - ChallengeModal, } from '@/views'; - +import { SNLog } from '@standardnotes/snjs'; +import angular from 'angular'; +import { AccountMenuDirective } from './components/AccountMenu'; +import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal'; +import { IconDirective } from './components/Icon'; +import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes'; +import { NoAccountWarningDirective } from './components/NoAccountWarning'; +import { NotesContextMenuDirective } from './components/NotesContextMenu'; +import { NotesListOptionsDirective } from './components/NotesListOptionsMenu'; +import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel'; +import { NotesViewDirective } from './components/NotesView'; +import { NoteTagsContainerDirective } from './components/NoteTagsContainer'; +import { ProtectedNoteOverlayDirective } from './components/ProtectedNoteOverlay'; +import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu/QuickSettingsMenu'; +import { SearchOptionsDirective } from './components/SearchOptions'; +import { SessionsModalDirective } from './components/SessionsModal'; import { autofocus, clickOutside, delayHide, elemReady, fileChange, - infiniteScroll, lowercase, selectOnFocus, snEnter, } from './directives/functional'; - import { ActionsMenu, EditorMenu, + HistoryMenu, InputModal, MenuRow, PanelResizer, PasswordWizard, PermissionsModal, RevisionPreviewModal, - HistoryMenu, SyncResolutionMenu, } from './directives/views'; - import { trusted } from './filters'; -import { isDev } from './utils'; +import { PreferencesDirective } from './preferences'; +import { PurchaseFlowDirective } from './purchaseFlow'; +import { configRoutes } from './routes'; +import { Bridge } from './services/bridge'; import { BrowserBridge } from './services/browserBridge'; import { startErrorReporting } from './services/errorReporting'; import { StartApplication } from './startApplication'; -import { Bridge } from './services/bridge'; -import { SessionsModalDirective } from './components/SessionsModal'; -import { NoAccountWarningDirective } from './components/NoAccountWarning'; -import { ProtectedNoteOverlayDirective } from './components/ProtectedNoteOverlay'; -import { SearchOptionsDirective } from './components/SearchOptions'; -import { AccountMenuDirective } from './components/AccountMenu'; -import { ConfirmSignoutDirective } from './components/ConfirmSignoutModal'; -import { MultipleSelectedNotesDirective } from './components/MultipleSelectedNotes'; -import { NotesContextMenuDirective } from './components/NotesContextMenu'; -import { NotesOptionsPanelDirective } from './components/NotesOptionsPanel'; -import { IconDirective } from './components/Icon'; -import { NoteTagsContainerDirective } from './components/NoteTagsContainer'; -import { PreferencesDirective } from './preferences'; -import { WebAppVersion, IsWebPlatform } from '@/version'; -import { NotesListOptionsDirective } from './components/NotesListOptionsMenu'; -import { PurchaseFlowDirective } from './purchaseFlow'; -import { QuickSettingsMenuDirective } from './components/QuickSettingsMenu/QuickSettingsMenu'; -import { ComponentViewDirective } from '@/components/ComponentView'; -import { TagsListDirective } from '@/components/TagsList'; -import { NotesViewDirective } from './components/NotesView'; -import { PinNoteButtonDirective } from '@/components/PinNoteButton'; -import { TagsSectionDirective } from './components/Tags/TagsSection'; +import { ApplicationGroup } from './ui_models/application_group'; +import { isDev } from './utils'; +import { AccountSwitcher } from './views/account_switcher/account_switcher'; function reloadHiddenFirefoxTab(): boolean { /** @@ -143,7 +139,6 @@ const startApplication: StartApplication = async function startApplication( .directive('applicationView', () => new ApplicationView()) .directive('noteGroupView', () => new NoteGroupViewDirective()) .directive('noteView', () => new NoteViewDirective()) - .directive('tagsView', () => new TagsView()) .directive('footerView', () => new FooterView()); // Directives - Functional @@ -154,7 +149,6 @@ const startApplication: StartApplication = async function startApplication( .directive('delayHide', delayHide) .directive('elemReady', elemReady) .directive('fileChange', fileChange) - .directive('infiniteScroll', [infiniteScroll]) .directive('lowercase', lowercase) .directive('selectOnFocus', ['$window', selectOnFocus]) .directive('snEnter', snEnter); @@ -188,8 +182,7 @@ const startApplication: StartApplication = async function startApplication( .directive('notesListOptionsMenu', NotesListOptionsDirective) .directive('icon', IconDirective) .directive('noteTagsContainer', NoteTagsContainerDirective) - .directive('tagsList', TagsListDirective) - .directive('tagsSection', TagsSectionDirective) + .directive('navigation', NavigationDirective) .directive('preferences', PreferencesDirective) .directive('purchaseFlow', PurchaseFlowDirective) .directive('notesView', NotesViewDirective) diff --git a/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx b/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx index 1a75878df..cdae6ca3c 100644 --- a/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx +++ b/app/assets/javascripts/components/AccountMenu/AdvancedOptions.tsx @@ -1,5 +1,6 @@ import { WebApplication } from '@/ui_models/application'; import { AppState } from '@/ui_models/app_state'; +import { isDev } from '@/utils'; import { observer } from 'mobx-react-lite'; import { FunctionComponent } from 'preact'; import { useState } from 'preact/hooks'; @@ -19,6 +20,12 @@ export const AdvancedOptions: FunctionComponent = observer( appState.accountMenu; const [showAdvanced, setShowAdvanced] = useState(false); + if (isDev && window._devAccountServer) { + setEnableServerOption(true); + setServer(window._devAccountServer); + application.setCustomHost(window._devAccountServer); + } + const handleServerOptionChange = (e: Event) => { if (e.target instanceof HTMLInputElement) { setEnableServerOption(e.target.checked); diff --git a/app/assets/javascripts/components/AccountMenu/SignIn.tsx b/app/assets/javascripts/components/AccountMenu/SignIn.tsx index f4bb3cf14..82da576f3 100644 --- a/app/assets/javascripts/components/AccountMenu/SignIn.tsx +++ b/app/assets/javascripts/components/AccountMenu/SignIn.tsx @@ -1,5 +1,6 @@ import { WebApplication } from '@/ui_models/application'; import { AppState } from '@/ui_models/app_state'; +import { isDev } from '@/utils'; import { observer } from 'mobx-react-lite'; import { FunctionComponent } from 'preact'; import { useEffect, useRef, useState } from 'preact/hooks'; @@ -32,6 +33,11 @@ export const SignInPane: FunctionComponent = observer( const emailInputRef = useRef(null); const passwordInputRef = useRef(null); + if (isDev && window._devAccountEmail) { + setEmail(window._devAccountEmail); + setPassword(window._devAccountPassword as string); + } + useEffect(() => { if (emailInputRef?.current) { emailInputRef.current?.focus(); diff --git a/app/assets/javascripts/components/AutocompleteTagResult.tsx b/app/assets/javascripts/components/AutocompleteTagResult.tsx index 84bb150ac..d732541af 100644 --- a/app/assets/javascripts/components/AutocompleteTagResult.tsx +++ b/app/assets/javascripts/components/AutocompleteTagResult.tsx @@ -21,6 +21,9 @@ export const AutocompleteTagResult = observer( const tagResultRef = useRef(null); + const title = tagResult.title; + const prefixTitle = appState.noteTags.getPrefixTitle(tagResult); + const onTagOptionClick = async (tag: SNTag) => { await appState.noteTags.addTagToActiveNote(tag); appState.noteTags.clearAutocompleteSearch(); @@ -86,9 +89,10 @@ export const AutocompleteTagResult = observer( > + {prefixTitle && {prefixTitle}} {autocompleteSearchQuery === '' - ? tagResult.title - : tagResult.title + ? title + : title .split(new RegExp(`(${autocompleteSearchQuery})`, 'gi')) .map((substring, index) => ( void; }; -type ListboxButtonProps = { - icon?: IconType; - value: string | null; - label: string; +type ListboxButtonProps = DropdownItem & { isExpanded: boolean; }; @@ -36,12 +34,13 @@ const CustomDropdownButton: FunctionComponent = ({ label, isExpanded, icon, + iconClassName = '', }) => ( <>
{icon ? (
- +
) : null}
{label}
@@ -65,6 +64,10 @@ export const Dropdown: FunctionComponent = ({ }) => { const [value, setValue] = useState(defaultValue); + useEffect(() => { + setValue(defaultValue); + }, [defaultValue]); + const labelId = `${id}-label`; const handleChange = (value: string) => { @@ -85,11 +88,13 @@ export const Dropdown: FunctionComponent = ({ children={({ value, label, isExpanded }) => { const current = items.find((item) => item.value === value); const icon = current ? current?.icon : null; + const iconClassName = current ? current?.iconClassName : null; return CustomDropdownButton({ - value, + value: value ? value : label.toLowerCase(), label, isExpanded, ...(icon ? { icon } : null), + ...(iconClassName ? { iconClassName } : null), }); }} /> @@ -104,7 +109,10 @@ export const Dropdown: FunctionComponent = ({ > {item.icon ? (
- +
) : null}
{item.label}
diff --git a/app/assets/javascripts/components/Icon.tsx b/app/assets/javascripts/components/Icon.tsx index 96eee992f..6d5b6d3bc 100644 --- a/app/assets/javascripts/components/Icon.tsx +++ b/app/assets/javascripts/components/Icon.tsx @@ -3,7 +3,9 @@ import PencilOffIcon from '../../icons/ic-pencil-off.svg'; import PlainTextIcon from '../../icons/ic-text-paragraph.svg'; import RichTextIcon from '../../icons/ic-text-rich.svg'; import TrashIcon from '../../icons/ic-trash.svg'; +import TrashFilledIcon from '../../icons/ic-trash-filled.svg'; import PinIcon from '../../icons/ic-pin.svg'; +import PinFilledIcon from '../../icons/ic-pin-filled.svg'; import UnpinIcon from '../../icons/ic-pin-off.svg'; import ArchiveIcon from '../../icons/ic-archive.svg'; import UnarchiveIcon from '../../icons/ic-unarchive.svg'; @@ -21,6 +23,7 @@ import AuthenticatorIcon from '../../icons/ic-authenticator.svg'; import SpreadsheetsIcon from '../../icons/ic-spreadsheets.svg'; import TasksIcon from '../../icons/ic-tasks.svg'; import MarkdownIcon from '../../icons/ic-markdown.svg'; +import NotesIcon from '../../icons/ic-notes.svg'; import CodeIcon from '../../icons/ic-code.svg'; import AccessibilityIcon from '../../icons/ic-accessibility.svg'; @@ -52,6 +55,7 @@ import ServerIcon from '../../icons/ic-server.svg'; import EyeIcon from '../../icons/ic-eye.svg'; import EyeOffIcon from '../../icons/ic-eye-off.svg'; import LockIcon from '../../icons/ic-lock.svg'; +import LockFilledIcon from '../../icons/ic-lock-filled.svg'; import ArrowsSortUpIcon from '../../icons/ic-arrows-sort-up.svg'; import ArrowsSortDownIcon from '../../icons/ic-arrows-sort-down.svg'; import WindowIcon from '../../icons/ic-window.svg'; @@ -66,9 +70,11 @@ import { FunctionalComponent } from 'preact'; const ICONS = { 'menu-arrow-down-alt': MenuArrowDownAlt, 'menu-arrow-right': MenuArrowRight, + notes: NotesIcon, 'arrows-sort-up': ArrowsSortUpIcon, 'arrows-sort-down': ArrowsSortDownIcon, lock: LockIcon, + 'lock-filled': LockFilledIcon, eye: EyeIcon, 'eye-off': EyeOffIcon, server: ServerIcon, @@ -89,7 +95,9 @@ const ICONS = { spreadsheets: SpreadsheetsIcon, tasks: TasksIcon, trash: TrashIcon, + 'trash-filled': TrashFilledIcon, pin: PinIcon, + 'pin-filled': PinFilledIcon, unpin: UnpinIcon, archive: ArchiveIcon, unarchive: UnarchiveIcon, @@ -130,11 +138,22 @@ export type IconType = keyof typeof ICONS; type Props = { type: IconType; className?: string; + ariaLabel?: string; }; -export const Icon: FunctionalComponent = ({ type, className = '' }) => { +export const Icon: FunctionalComponent = ({ + type, + className = '', + ariaLabel, +}) => { const IconComponent = ICONS[type]; - return ; + return ( + + ); }; export const IconDirective = toDirective(Icon, { diff --git a/app/assets/javascripts/components/Navigation.tsx b/app/assets/javascripts/components/Navigation.tsx new file mode 100644 index 000000000..b3576ac0f --- /dev/null +++ b/app/assets/javascripts/components/Navigation.tsx @@ -0,0 +1,123 @@ +import { ComponentView } from '@/components/ComponentView'; +import { PanelResizer } from '@/components/PanelResizer'; +import { SmartTagsSection } from '@/components/Tags/SmartTagsSection'; +import { TagsSection } from '@/components/Tags/TagsSection'; +import { toDirective } from '@/components/utils'; +import { + PanelSide, + ResizeFinishCallback, +} from '@/directives/views/panelResizer'; +import { WebApplication } from '@/ui_models/application'; +import { PANEL_NAME_NAVIGATION } from '@/views/constants'; +import { PrefKey } from '@standardnotes/snjs'; +import { observer } from 'mobx-react-lite'; +import { FunctionComponent } from 'preact'; +import { useCallback, useMemo, useState } from 'preact/hooks'; +import { PremiumModalProvider } from './Premium'; + +type Props = { + application: WebApplication; +}; + +const NAVIGATION_SELECTOR = 'navigation'; + +const useNavigationPanelRef = (): [HTMLDivElement | null, () => void] => { + const [panelRef, setPanelRefInternal] = useState(null); + + const setPanelRefPublic = useCallback(() => { + const elem = document.querySelector( + NAVIGATION_SELECTOR + ) as HTMLDivElement | null; + setPanelRefInternal(elem); + }, [setPanelRefInternal]); + + return [panelRef, setPanelRefPublic]; +}; + +export const Navigation: FunctionComponent = observer( + ({ application }) => { + const appState = useMemo(() => application.getAppState(), [application]); + const componentViewer = appState.foldersComponentViewer; + const enableNativeSmartTagsFeature = + appState.features.enableNativeSmartTagsFeature; + const [panelRef, setPanelRef] = useNavigationPanelRef(); + + const onCreateNewTag = useCallback(() => { + appState.tags.createNewTemplate(); + }, [appState]); + + const panelResizeFinishCallback: ResizeFinishCallback = useCallback( + (_lastWidth, _lastLeft, _isMaxWidth, isCollapsed) => { + appState.noteTags.reloadTagsContainerMaxWidth(); + appState.panelDidResize(PANEL_NAME_NAVIGATION, isCollapsed); + }, + [appState] + ); + + const panelWidthEventCallback = useCallback(() => { + appState.noteTags.reloadTagsContainerMaxWidth(); + }, [appState]); + + return ( + + + + ); + } +); + +export const NavigationDirective = toDirective(Navigation); diff --git a/app/assets/javascripts/components/NoteTag.tsx b/app/assets/javascripts/components/NoteTag.tsx index 91dc12aba..372f6017e 100644 --- a/app/assets/javascripts/components/NoteTag.tsx +++ b/app/assets/javascripts/components/NoteTag.tsx @@ -10,7 +10,9 @@ type Props = { }; export const NoteTag = observer(({ appState, tag }: Props) => { - const { autocompleteInputFocused, focusedTagUuid, tags } = appState.noteTags; + const noteTags = appState.noteTags; + + const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags; const [showDeleteButton, setShowDeleteButton] = useState(false); const [tagClicked, setTagClicked] = useState(false); @@ -18,6 +20,10 @@ export const NoteTag = observer(({ appState, tag }: Props) => { const tagRef = useRef(null); + const title = tag.title; + const prefixTitle = noteTags.getPrefixTitle(tag); + const longTitle = noteTags.getLongTitle(tag); + const deleteTag = () => { appState.noteTags.focusPreviousTag(tag); appState.noteTags.removeTagFromActiveNote(tag); @@ -32,7 +38,7 @@ export const NoteTag = observer(({ appState, tag }: Props) => { const onTagClick = (event: MouseEvent) => { if (tagClicked && event.target !== deleteTagRef.current) { setTagClicked(false); - appState.setSelectedTag(tag); + appState.selectedTag = tag; } else { setTagClicked(true); } @@ -97,10 +103,12 @@ export const NoteTag = observer(({ appState, tag }: Props) => { onFocus={onFocus} onBlur={onBlur} tabIndex={getTabIndex()} + title={longTitle} > - {tag.title} + {prefixTitle && {prefixTitle}} + {title} {showDeleteButton && (
+ )} + + {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 %>" + > diff --git a/package.json b/package.json index 3530dcbbc..005ed9994 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "standard-notes-web", - "version": "3.9.13", + "version": "3.9.14", "license": "AGPL-3.0-or-later", "repository": { "type": "git", @@ -28,7 +28,7 @@ "@babel/preset-typescript": "^7.15.0", "@reach/disclosure": "^0.16.2", "@reach/visually-hidden": "^0.16.0", - "@standardnotes/components": "^1.2.4", + "@standardnotes/components": "^1.2.5", "@svgr/webpack": "^5.5.0", "@types/angular": "^1.8.3", "@types/jest": "^27.0.3", @@ -68,7 +68,7 @@ "pug-loader": "^2.4.0", "sass-loader": "^12.2.0", "serve-static": "^1.14.1", - "sn-stylekit": "5.2.20", + "sn-stylekit": "5.2.21", "svg-jest": "^1.0.1", "ts-jest": "^27.0.7", "ts-loader": "^9.2.6", @@ -87,9 +87,10 @@ "@reach/dialog": "^0.16.2", "@reach/listbox": "^0.16.2", "@reach/tooltip": "^0.16.2", - "@standardnotes/features": "1.20.5", + "@standardnotes/features": "1.20.6", + "@standardnotes/settings": "^1.9.0", "@standardnotes/sncrypto-web": "1.5.3", - "@standardnotes/snjs": "2.31.25", + "@standardnotes/snjs": "2.35.3", "mobx": "^6.3.5", "mobx-react-lite": "^3.2.2", "preact": "^10.5.15", diff --git a/public/components/checksums.json b/public/components/checksums.json index a3e03c005..ae808a384 100644 --- a/public/components/checksums.json +++ b/public/components/checksums.json @@ -30,9 +30,9 @@ "binary": "1928aa349a04471afd273725cc4befe711eeda91aca70aee00c7ad356241c252" }, "org.standardnotes.theme-dynamic": { - "version": "1.0.1", - "base64": "ead03c37f6cb3b1858793db4433331143f916aebb3a13ab07637c27dcf310034", - "binary": "fb93157b0249f577e7a5e58f8bb562e68435f683cc66f7adace12b935fa3eee1" + "version": "1.0.2", + "base64": "7a5075a265e67ae54cb7c49bad6aa8b6f84f8fb4883ca859327e8e794cbfff0c", + "binary": "01a1356c879aa1ef38e856ee0028e6afbff1fa40544e24e3297af863d6cac669" }, "org.standardnotes.code-editor": { "version": "1.3.8", diff --git a/public/components/org.standardnotes.theme-dynamic/dist/dist.css b/public/components/org.standardnotes.theme-dynamic/dist/dist.css index 568fcdd78..f943962c4 100644 --- a/public/components/org.standardnotes.theme-dynamic/dist/dist.css +++ b/public/components/org.standardnotes.theme-dynamic/dist/dist.css @@ -1,10 +1,12 @@ -.section.tags { +.section.tags, +navigation { flex: none !important; width: 120px !important; transition: width 0.25s; } -.section.tags:hover { +.section.tags:hover, +navigation:hover { flex: initial; width: 180px !important; transition: width 0.25s; diff --git a/public/components/org.standardnotes.theme-dynamic/dist/dist.css.map b/public/components/org.standardnotes.theme-dynamic/dist/dist.css.map index e32d7e9f1..657138b14 100644 --- a/public/components/org.standardnotes.theme-dynamic/dist/dist.css.map +++ b/public/components/org.standardnotes.theme-dynamic/dist/dist.css.map @@ -1,6 +1,6 @@ { "version": 3, -"mappings": "AAAA,aAAc;EACZ,IAAI,EAAE,eAAe;EACrB,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW;;;AAGzB,mBAAoB;EAClB,IAAI,EAAE,OAAO;EACb,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW;;;AAGzB;UACW;EACT,IAAI,EAAE,eAAe;EACrB,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW;;;AAGzB;gBACiB;EACf,IAAI,EAAE,OAAO;EACb,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW", +"mappings": "AAAA;UACW;EACT,IAAI,EAAE,eAAe;EACrB,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW;;;AAGzB;gBACiB;EACf,IAAI,EAAE,OAAO;EACb,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW;;;AAGzB;UACW;EACT,IAAI,EAAE,eAAe;EACrB,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW;;;AAGzB;gBACiB;EACf,IAAI,EAAE,OAAO;EACb,KAAK,EAAE,gBAAgB;EACvB,UAAU,EAAE,WAAW", "sources": ["../src/main.scss"], "names": [], "file": "dist.css" diff --git a/public/components/org.standardnotes.theme-dynamic/package.json b/public/components/org.standardnotes.theme-dynamic/package.json index 154242d0d..3bbb618dc 100644 --- a/public/components/org.standardnotes.theme-dynamic/package.json +++ b/public/components/org.standardnotes.theme-dynamic/package.json @@ -1,6 +1,6 @@ { "name": "sn-theme-dynamic", - "version": "1.0.1", + "version": "1.0.2", "main": "dist/dist.css", "devDependencies": { "grunt": "^1.0.1", diff --git a/yarn.lock b/yarn.lock index 8b2c7da61..ce89058d7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2616,10 +2616,10 @@ resolved "https://registry.yarnpkg.com/@standardnotes/common/-/common-1.2.1.tgz#9db212db86ccbf08b347da02549b3dbe4bedbb02" integrity sha512-HilBxS50CBlC6TJvy1mrnhGVDzOH63M/Jf+hyMxQ0Vt1nYzpd0iyxVEUrgMh7ZiyO1b9CLnCDED99Jy9rnZWVQ== -"@standardnotes/components@^1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@standardnotes/components/-/components-1.2.4.tgz#29cab333551e91d8c88c920a7d5e3bd863d55f17" - integrity sha512-Ous0tCCnzIH4IHN/3y0C03XYlpTfxP+mfsPNJN/kPhC0Z+91OV+z6Y/qK1zxe6khsegUEO6k01qyPu5EJLhorg== +"@standardnotes/components@^1.2.5": + version "1.2.5" + resolved "https://registry.yarnpkg.com/@standardnotes/components/-/components-1.2.5.tgz#3e1e959bc40e5e0d34a0a44ac68fda4b29807f8e" + integrity sha512-/E87R6wpMK0nLeS6ugCyjHht3M5hqZUH4lWA179jE9N4A6WXYvlqcFYO4t2RiG6yErRPZOHfpUFFsL1agXVuwA== "@standardnotes/domain-events@2.5.1": version "2.5.1" @@ -2628,18 +2628,26 @@ dependencies: "@standardnotes/auth" "^3.8.1" -"@standardnotes/features@1.20.5", "@standardnotes/features@^1.20.5": - version "1.20.5" - resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.20.5.tgz#443e3ae84d13f0aaa35708c5c237dac8041cb50d" - integrity sha512-4QQeWLk2frEF9UYOfnuQoulkUJ3PooVLasPUA+zva+KIokBiyPmVPsi3HAYXlHqowu+lDhKU2pUklLhm1ePvJw== +"@standardnotes/features@1.20.6": + version "1.20.6" + resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.20.6.tgz#94d397892dd12f76a10c89c70092933627a9c457" + integrity sha512-/w8+/8J8UNJ+DAsOud8XbWkeUBN6eb+5+Ic4NgkXYkx/wv6sTDk9XVc+mOhxOkYlb2iy85JDmoLAwu+GW/3Gtg== dependencies: "@standardnotes/auth" "3.8.3" "@standardnotes/common" "1.2.1" -"@standardnotes/settings@^1.8.1": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@standardnotes/settings/-/settings-1.8.1.tgz#a448f2b48a994dab2a84dc93255cd2f9ea0df6af" - integrity sha512-hQFg4xYkvI7WWRCxYjbyiNW7EjaUlmASGXsd/AoYlHGrlYhTnOEajBEh3sSMMV0b7UUps0wGZcGjQMpq5fabuw== +"@standardnotes/features@^1.20.7": + version "1.20.7" + resolved "https://registry.yarnpkg.com/@standardnotes/features/-/features-1.20.7.tgz#d666569492e942eaecc05e40a79d50d33df4fbe9" + integrity sha512-eaZu/+PvHYXWaq6r3ET87t52lZqFknZVUEjspAL34Fdr+5cDma5ZRoylx6hPCVDO9VpHd6fjGWlS+5kZ+qJ+bA== + dependencies: + "@standardnotes/auth" "3.8.3" + "@standardnotes/common" "1.2.1" + +"@standardnotes/settings@^1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@standardnotes/settings/-/settings-1.9.0.tgz#0f01da5f6782363e4d77ee584b40f8614c555626" + integrity sha512-y+Mh7NuXtekEDr4PAvzg9KcRaCdd+0zlTXWO2D5MG28lLv/uhZmSsyWxZCVZqW3Rx6vz3c9IJdi7SoXN51gzSQ== "@standardnotes/sncrypto-common@1.5.2", "@standardnotes/sncrypto-common@^1.5.2": version "1.5.2" @@ -2655,16 +2663,16 @@ buffer "^6.0.3" libsodium-wrappers "^0.7.9" -"@standardnotes/snjs@2.31.25": - version "2.31.25" - resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.31.25.tgz#715dbecc0c71cc22a81d93b3aabfe7b436a480ac" - integrity sha512-DF0ZcIHfxIpaFepCIXNCVipwjgoy60FrSy5th0kNj5TCOYHryQ9bOiaWXQKHrQUi/8sKYJ+/W1pwRjz6+MpZMw== +"@standardnotes/snjs@2.35.3": + version "2.35.3" + resolved "https://registry.yarnpkg.com/@standardnotes/snjs/-/snjs-2.35.3.tgz#e8604329930317000fbec239534a3020d7e7aefb" + integrity sha512-Cooby9VKS92Zo5xWKQbtCDPTzr2ugsuHzp054NaQTwhGJO/WpgAX1VVoW9MsN4dQxOi6Kf2sIRBUZU4TL+caTQ== dependencies: "@standardnotes/auth" "3.8.1" "@standardnotes/common" "1.2.1" "@standardnotes/domain-events" "2.5.1" - "@standardnotes/features" "^1.20.5" - "@standardnotes/settings" "^1.8.1" + "@standardnotes/features" "^1.20.7" + "@standardnotes/settings" "^1.9.0" "@standardnotes/sncrypto-common" "1.5.2" "@svgr/babel-plugin-add-jsx-attribute@^5.4.0": @@ -9264,10 +9272,10 @@ slice-ansi@^5.0.0: ansi-styles "^6.0.0" is-fullwidth-code-point "^4.0.0" -sn-stylekit@5.2.20: - version "5.2.20" - resolved "https://registry.yarnpkg.com/sn-stylekit/-/sn-stylekit-5.2.20.tgz#c18f40ff3aaf4c59af89152439a8efbdde35f2dd" - integrity sha512-JymHBiZOzQPfCqHYgnVPSA2PwJqiKR268qqQoEMqI85MMAWSG3WYzuKEbd0LgfIQAKLElCxJjeZkrhejyRg+2A== +sn-stylekit@5.2.21: + version "5.2.21" + resolved "https://registry.yarnpkg.com/sn-stylekit/-/sn-stylekit-5.2.21.tgz#5aec6c329949bda64a1e3c563ee594b141295d27" + integrity sha512-rjlgo42A/kx+M4iY7HYRpnQyp4dLb2HQpEMHz+CYumOzTf/lsRy0Up5HI1haNK4/JMmpq36Eb/7BMDmvLpdXnQ== dependencies: "@reach/listbox" "^0.15.0" "@reach/menu-button" "^0.15.1"