feat: per-tag display preferences (#1868)

This commit is contained in:
Mo
2022-10-25 07:27:26 -05:00
committed by GitHub
parent 9248d0ff16
commit ee7f11c933
32 changed files with 783 additions and 413 deletions

View File

@@ -1,6 +1,6 @@
import { CreateItemDelta } from './../Index/ItemDelta'
import { DeletedPayload } from './../../Abstract/Payload/Implementations/DeletedPayload'
import { createFile, createNote, createTag, mockUuid, pinnedContent } from './../../Utilities/Test/SpecUtils'
import { createFile, createNote, createTagWithTitle, mockUuid, pinnedContent } from './../../Utilities/Test/SpecUtils'
import { ContentType } from '@standardnotes/common'
import { DeletedItem, EncryptedItem } from '../../Abstract/Item'
import { EncryptedPayload, PayloadTimestampDefaults } from '../../Abstract/Payload'
@@ -174,7 +174,7 @@ describe('item display controller', () => {
it('should ignore items not matching content type on construction', () => {
const collection = new ItemCollection()
const note = createNoteWithContent({ title: 'a' })
const tag = createTag()
const tag = createTagWithTitle()
collection.set([note, tag])
const controller = new ItemDisplayController(collection, [ContentType.Note], {
@@ -187,7 +187,7 @@ describe('item display controller', () => {
it('should ignore items not matching content type on sort change', () => {
const collection = new ItemCollection()
const note = createNoteWithContent({ title: 'a' })
const tag = createTag()
const tag = createTagWithTitle()
collection.set([note, tag])
const controller = new ItemDisplayController(collection, [ContentType.Note], {
@@ -207,7 +207,7 @@ describe('item display controller', () => {
sortBy: 'title',
sortDirection: 'asc',
})
const tag = createTag()
const tag = createTagWithTitle()
const delta = CreateItemDelta({ inserted: [tag], changed: [note] })
collection.onChange(delta)

View File

@@ -6,6 +6,9 @@ import { SystemViewId } from './SystemViewId'
import { EmojiString, IconType } from '../../Utilities/Icon/IconType'
import { SmartViewDefaultIconName, systemViewIcon } from './SmartViewIcons'
import { SmartViewContent } from './SmartViewContent'
import { TagPreferences } from '../Tag/TagPreferences'
import { ItemInterface } from '../../Abstract/Item'
import { ContentType } from '@standardnotes/common'
export const SMART_TAG_DSL_PREFIX = '!['
@@ -13,10 +16,13 @@ export function isSystemView(view: SmartView): boolean {
return Object.values(SystemViewId).includes(view.uuid as SystemViewId)
}
export const isSmartView = (x: ItemInterface): x is SmartView => x.content_type === ContentType.SmartView
export class SmartView extends DecryptedItem<SmartViewContent> {
public readonly predicate!: PredicateInterface<DecryptedItem>
public readonly title: string
public readonly iconString: IconType | EmojiString
public readonly preferences?: TagPreferences
constructor(payload: DecryptedPayloadInterface<SmartViewContent>) {
super(payload)
@@ -28,6 +34,8 @@ export class SmartView extends DecryptedItem<SmartViewContent> {
this.iconString = this.payload.content.iconString || SmartViewDefaultIconName
}
this.preferences = this.payload.content.preferences
try {
this.predicate = this.content.predicate && predicateFromJson(this.content.predicate)
} catch (error) {

View File

@@ -38,4 +38,10 @@ describe('SNTag Tests', () => {
expect(tag.noteCount).toEqual(2)
})
it('preferences should be undefined if not specified', () => {
const tag = create('helloworld', [])
expect(tag.preferences).toBeFalsy()
})
})

View File

@@ -6,6 +6,7 @@ import { ContentReference } from '../../Abstract/Reference/ContentReference'
import { isTagToParentTagReference } from '../../Abstract/Reference/Functions'
import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload'
import { TagContent, TagContentSpecialized } from './TagContent'
import { TagPreferences } from './TagPreferences'
export const TagFolderDelimitter = '.'
@@ -17,12 +18,14 @@ export class SNTag extends DecryptedItem<TagContent> implements TagContentSpecia
public readonly title: string
public readonly iconString: VectorIconNameOrEmoji
public readonly expanded: boolean
public readonly preferences?: TagPreferences
constructor(payload: DecryptedPayloadInterface<TagContent>) {
super(payload)
this.title = this.payload.content.title || ''
this.expanded = this.payload.content.expanded != undefined ? this.payload.content.expanded : true
this.iconString = this.payload.content.iconString || DefaultTagIconName
this.preferences = this.payload.content.preferences
}
get noteReferences(): ContentReference[] {

View File

@@ -1,11 +1,13 @@
import { IconType } from './../../Utilities/Icon/IconType'
import { ItemContent } from '../../Abstract/Content/ItemContent'
import { EmojiString } from '../../Utilities/Icon/IconType'
import { TagPreferences } from './TagPreferences'
export interface TagContentSpecialized {
title: string
expanded: boolean
iconString: IconType | EmojiString
preferences?: TagPreferences
}
export type TagContent = TagContentSpecialized & ItemContent

View File

@@ -1,6 +1,6 @@
import { ContentType } from '@standardnotes/common'
import { ContentReferenceType, MutationType } from '../../Abstract/Item'
import { createFile, createTag } from '../../Utilities/Test/SpecUtils'
import { createFile, createTagWithContent, createTagWithTitle } from '../../Utilities/Test/SpecUtils'
import { SNTag } from './Tag'
import { TagMutator } from './TagMutator'
@@ -8,7 +8,7 @@ describe('tag mutator', () => {
it('should add file to tag', () => {
const file = createFile()
const tag = createTag()
const tag = createTagWithTitle()
const mutator = new TagMutator(tag, MutationType.UpdateUserTimestamps)
mutator.addFile(file)
const result = mutator.getResult()
@@ -23,7 +23,7 @@ describe('tag mutator', () => {
it('should remove file from tag', () => {
const file = createFile()
const tag = createTag()
const tag = createTagWithTitle()
const addMutator = new TagMutator(tag, MutationType.UpdateUserTimestamps)
addMutator.addFile(file)
const addResult = addMutator.getResult()
@@ -35,4 +35,36 @@ describe('tag mutator', () => {
expect(removeResult.content.references).toHaveLength(0)
})
it('preferences should be undefined if previously undefined', () => {
const tag = createTagWithTitle()
const mutator = new TagMutator(tag, MutationType.UpdateUserTimestamps)
const result = mutator.getResult()
expect(result.content.preferences).toBeFalsy()
})
it('preferences should be lazy-created if attempting to set a property', () => {
const tag = createTagWithTitle()
const mutator = new TagMutator(tag, MutationType.UpdateUserTimestamps)
mutator.preferences.sortBy = 'content_type'
const result = mutator.getResult()
expect(result.content.preferences?.sortBy).toEqual('content_type')
})
it('preferences should be nulled if client is reseting', () => {
const tag = createTagWithContent({
title: 'foo',
preferences: {
sortBy: 'content_type',
},
})
const mutator = new TagMutator(tag, MutationType.UpdateUserTimestamps)
mutator.preferences = undefined
const result = mutator.getResult()
expect(result.content.preferences).toBeFalsy()
})
})

View File

@@ -8,8 +8,18 @@ import { TagToParentTagReference } from '../../Abstract/Reference/TagToParentTag
import { ContentReferenceType } from '../../Abstract/Reference/ContenteReferenceType'
import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator'
import { TagToFileReference } from '../../Abstract/Reference/TagToFileReference'
import { TagPreferences } from './TagPreferences'
import { DecryptedItemInterface, MutationType } from '../../Abstract/Item'
export class TagMutator extends DecryptedItemMutator<TagContent> {
private mutablePreferences?: TagPreferences
constructor(item: DecryptedItemInterface<TagContent>, type: MutationType) {
super(item, type)
this.mutablePreferences = this.mutableContent.preferences
}
set title(title: string) {
this.mutableContent.title = title
}
@@ -22,6 +32,20 @@ export class TagMutator extends DecryptedItemMutator<TagContent> {
this.mutableContent.iconString = iconString
}
get preferences(): TagPreferences {
if (!this.mutablePreferences) {
this.mutableContent.preferences = {}
this.mutablePreferences = this.mutableContent.preferences
}
return this.mutablePreferences
}
set preferences(preferences: TagPreferences | undefined) {
this.mutablePreferences = preferences
this.mutableContent.preferences = this.mutablePreferences
}
public makeChildOf(tag: SNTag): void {
const references = this.immutableItem.references.filter((ref) => !isTagToParentTagReference(ref))

View File

@@ -0,0 +1,19 @@
import { FeatureIdentifier } from '@standardnotes/features'
import { NewNoteTitleFormat } from '../UserPrefs'
import { CollectionSortProperty } from './../../Runtime/Collection/CollectionSort'
export interface TagPreferences {
sortBy?: CollectionSortProperty
sortReverse?: boolean
showArchived?: boolean
showTrashed?: boolean
hideProtected?: boolean
hidePinned?: boolean
hideNotePreview?: boolean
hideDate?: boolean
hideTags?: boolean
hideEditorIcon?: boolean
newNoteTitleFormat?: NewNoteTitleFormat
customNoteTitleFormat?: string
editorIdentifier?: FeatureIdentifier | string
}

View File

@@ -1,3 +1,4 @@
export * from './Tag'
export * from './TagMutator'
export * from './TagContent'
export * from './TagPreferences'

View File

@@ -41,7 +41,21 @@ export const createNoteWithContent = (content: Partial<NoteContent>, createdAt?:
)
}
export const createTag = (title = 'photos') => {
export const createTagWithContent = (content: Partial<TagContent>): SNTag => {
return new SNTag(
new DecryptedPayload(
{
uuid: mockUuid(),
content_type: ContentType.Tag,
content: FillItemContent<TagContent>(content),
...PayloadTimestampDefaults(),
},
PayloadSource.Constructor,
),
)
}
export const createTagWithTitle = (title = 'photos') => {
return new SNTag(
new DecryptedPayload(
{

View File

@@ -1,5 +1,5 @@
import { Uuid } from '@standardnotes/common'
import { ComponentArea } from '@standardnotes/features'
import { ComponentArea, FeatureIdentifier } from '@standardnotes/features'
import { ActionObserver, PermissionDialog, SNComponent, SNNote } from '@standardnotes/models'
import { DesktopManagerInterface } from '../Device/DesktopManagerInterface'
@@ -21,4 +21,5 @@ export interface ComponentManagerInterface {
): ComponentViewerInterface
presentPermissionsDialog(_dialog: PermissionDialog): void
getDefaultEditor(): SNComponent | undefined
componentWithIdentifier(identifier: FeatureIdentifier | string): SNComponent | undefined
}

View File

@@ -35,7 +35,8 @@ export class NoteViewController implements ItemViewControllerInterface {
public isTemplateNote = false
private saveTimeout?: ReturnType<typeof setTimeout>
private defaultTitle: string | undefined
private defaultTag: UuidString | undefined
private defaultTagUuid: UuidString | undefined
private defaultTag?: SNTag
public runtimeId = `${Math.random()}`
constructor(
@@ -49,7 +50,11 @@ export class NoteViewController implements ItemViewControllerInterface {
if (templateNoteOptions) {
this.defaultTitle = templateNoteOptions.title
this.defaultTag = templateNoteOptions.tag
this.defaultTagUuid = templateNoteOptions.tag
}
if (this.defaultTagUuid) {
this.defaultTag = this.application.items.findItem(this.defaultTagUuid) as SNTag
}
}
@@ -67,20 +72,27 @@ export class NoteViewController implements ItemViewControllerInterface {
async initialize(addTagHierarchy: boolean): Promise<void> {
if (!this.item) {
const editor = this.application.componentManager.getDefaultEditor()
const editorIdentifier =
this.defaultTag?.preferences?.editorIdentifier ||
this.application.componentManager.getDefaultEditor()?.identifier
const defaultEditor = editorIdentifier
? this.application.componentManager.componentWithIdentifier(editorIdentifier)
: undefined
const note = this.application.mutator.createTemplateItem<NoteContent, SNNote>(ContentType.Note, {
text: '',
title: this.defaultTitle || '',
noteType: editor?.noteType || NoteType.Plain,
editorIdentifier: editor?.identifier,
noteType: defaultEditor?.noteType || NoteType.Plain,
editorIdentifier: editorIdentifier,
references: [],
})
this.isTemplateNote = true
this.item = note
if (this.defaultTag) {
const tag = this.application.items.findItem(this.defaultTag) as SNTag
if (this.defaultTagUuid) {
const tag = this.application.items.findItem(this.defaultTagUuid) as SNTag
await this.application.items.addTagToNote(note, tag, addTagHierarchy)
}

View File

@@ -318,4 +318,16 @@ export class WebApplication extends SNApplication implements WebApplicationInter
return true
}
entitledToPerTagPreferences(): boolean {
return this.hasValidSubscription()
}
hasValidSubscription(): boolean {
return this.getViewControllerManager().subscriptionController.hasValidSubscription()
}
openPurchaseFlow(): void {
this.getViewControllerManager().purchaseFlowController.openPurchaseFlow()
}
}

View File

@@ -260,15 +260,18 @@ const ContentListView: FunctionComponent<Props> = ({
}
}}
/>
<ContentListHeader
application={application}
panelTitle={panelTitle}
icon={icon}
addButtonLabel={addButtonLabel}
addNewItem={addNewItem}
isFilesSmartView={isFilesSmartView}
optionsSubtitle={optionsSubtitle}
/>
{selectedTag && (
<ContentListHeader
application={application}
panelTitle={panelTitle}
icon={icon}
addButtonLabel={addButtonLabel}
addNewItem={addNewItem}
isFilesSmartView={isFilesSmartView}
optionsSubtitle={optionsSubtitle}
selectedTag={selectedTag}
/>
)}
<SearchBar itemListController={itemListController} searchOptionsController={searchOptionsController} />
<NoAccountWarning
accountMenuController={accountMenuController}

View File

@@ -7,19 +7,17 @@ import DisplayOptionsMenu from './DisplayOptionsMenu'
import { NavigationMenuButton } from '@/Components/NavigationMenu/NavigationMenu'
import { IconType } from '@standardnotes/snjs'
import RoundIconButton from '@/Components/Button/RoundIconButton'
import { AnyTag } from '@/Controllers/Navigation/AnyTagType'
type Props = {
application: {
getPreference: WebApplication['getPreference']
setPreference: WebApplication['setPreference']
isNativeMobileWeb: WebApplication['isNativeMobileWeb']
}
application: WebApplication
panelTitle: string
icon?: IconType | string
addButtonLabel: string
addNewItem: () => void
isFilesSmartView: boolean
optionsSubtitle?: string
selectedTag: AnyTag
}
const ContentListHeader = ({
@@ -30,6 +28,7 @@ const ContentListHeader = ({
addNewItem,
isFilesSmartView,
optionsSubtitle,
selectedTag,
}: Props) => {
const displayOptionsContainerRef = useRef<HTMLDivElement>(null)
const displayOptionsButtonRef = useRef<HTMLButtonElement>(null)
@@ -79,6 +78,7 @@ const ContentListHeader = ({
closeDisplayOptionsMenu={toggleDisplayOptionsMenu}
isFilesSmartView={isFilesSmartView}
isOpen={showDisplayOptionsMenu}
selectedTag={selectedTag}
/>
</Popover>
</div>

View File

@@ -1,6 +1,16 @@
import { CollectionSort, CollectionSortProperty, PrefKey } from '@standardnotes/snjs'
import {
CollectionSort,
CollectionSortProperty,
IconType,
isSmartView,
isSystemView,
PrefKey,
TagMutator,
TagPreferences,
VectorIconNameOrEmoji,
} from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useCallback, useState } from 'react'
import { FunctionComponent, useCallback, useEffect, useState } from 'react'
import Icon from '@/Components/Icon/Icon'
import Menu from '@/Components/Menu/Menu'
import MenuItem from '@/Components/Menu/MenuItem'
@@ -8,59 +18,104 @@ import MenuItemSeparator from '@/Components/Menu/MenuItemSeparator'
import { MenuItemType } from '@/Components/Menu/MenuItemType'
import { DisplayOptionsMenuProps } from './DisplayOptionsMenuProps'
import { PrefDefaults } from '@/Constants/PrefDefaults'
import NewNotePreferences from './NewNotePreferences'
import { PreferenceMode } from './PreferenceMode'
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '@/Components/Icon/PremiumFeatureIcon'
import Button from '@/Components/Button/Button'
import { classNames } from '@/Utils/ConcatenateClassNames'
const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
closeDisplayOptionsMenu,
application,
isOpen,
isFilesSmartView,
selectedTag,
}) => {
const [sortBy, setSortBy] = useState(() =>
application.getPreference(PrefKey.SortNotesBy, PrefDefaults[PrefKey.SortNotesBy]),
)
const [sortReverse, setSortReverse] = useState(() =>
application.getPreference(PrefKey.SortNotesReverse, PrefDefaults[PrefKey.SortNotesReverse]),
)
const [hidePreview, setHidePreview] = useState(() =>
application.getPreference(PrefKey.NotesHideNotePreview, PrefDefaults[PrefKey.NotesHideNotePreview]),
)
const [hideDate, setHideDate] = useState(() =>
application.getPreference(PrefKey.NotesHideDate, PrefDefaults[PrefKey.NotesHideDate]),
)
const [hideTags, setHideTags] = useState(() =>
application.getPreference(PrefKey.NotesHideTags, PrefDefaults[PrefKey.NotesHideTags]),
)
const [hidePinned, setHidePinned] = useState(() =>
application.getPreference(PrefKey.NotesHidePinned, PrefDefaults[PrefKey.NotesHidePinned]),
)
const [showArchived, setShowArchived] = useState(() =>
application.getPreference(PrefKey.NotesShowArchived, PrefDefaults[PrefKey.NotesShowArchived]),
)
const [showTrashed, setShowTrashed] = useState(() =>
application.getPreference(PrefKey.NotesShowTrashed, PrefDefaults[PrefKey.NotesShowTrashed]),
)
const [hideProtected, setHideProtected] = useState(() =>
application.getPreference(PrefKey.NotesHideProtected, PrefDefaults[PrefKey.NotesHideProtected]),
)
const [hideEditorIcon, setHideEditorIcon] = useState(() =>
application.getPreference(PrefKey.NotesHideEditorIcon, PrefDefaults[PrefKey.NotesHideEditorIcon]),
const isSystemTag = isSmartView(selectedTag) && isSystemView(selectedTag)
const [currentMode, setCurrentMode] = useState<PreferenceMode>(selectedTag.preferences ? 'tag' : 'global')
const [preferences, setPreferences] = useState<TagPreferences>({})
const hasSubscription = application.hasValidSubscription()
const controlsDisabled = currentMode === 'tag' && !hasSubscription
const reloadPreferences = useCallback(() => {
const globalValues: TagPreferences = {
sortBy: application.getPreference(PrefKey.SortNotesBy, PrefDefaults[PrefKey.SortNotesBy]),
sortReverse: application.getPreference(PrefKey.SortNotesReverse, PrefDefaults[PrefKey.SortNotesReverse]),
showArchived: application.getPreference(PrefKey.NotesShowArchived, PrefDefaults[PrefKey.NotesShowArchived]),
showTrashed: application.getPreference(PrefKey.NotesShowTrashed, PrefDefaults[PrefKey.NotesShowTrashed]),
hideProtected: application.getPreference(PrefKey.NotesHideProtected, PrefDefaults[PrefKey.NotesHideProtected]),
hidePinned: application.getPreference(PrefKey.NotesHidePinned, PrefDefaults[PrefKey.NotesHidePinned]),
hideNotePreview: application.getPreference(
PrefKey.NotesHideNotePreview,
PrefDefaults[PrefKey.NotesHideNotePreview],
),
hideDate: application.getPreference(PrefKey.NotesHideDate, PrefDefaults[PrefKey.NotesHideDate]),
hideTags: application.getPreference(PrefKey.NotesHideTags, PrefDefaults[PrefKey.NotesHideTags]),
hideEditorIcon: application.getPreference(PrefKey.NotesHideEditorIcon, PrefDefaults[PrefKey.NotesHideEditorIcon]),
newNoteTitleFormat: application.getPreference(
PrefKey.NewNoteTitleFormat,
PrefDefaults[PrefKey.NewNoteTitleFormat],
),
customNoteTitleFormat: application.getPreference(
PrefKey.CustomNoteTitleFormat,
PrefDefaults[PrefKey.CustomNoteTitleFormat],
),
}
if (currentMode === 'global') {
setPreferences(globalValues)
} else {
setPreferences({
...globalValues,
...selectedTag.preferences,
})
}
}, [currentMode, setPreferences, selectedTag, application])
useEffect(() => {
reloadPreferences()
}, [reloadPreferences])
const changePreferences = useCallback(
async (properties: Partial<TagPreferences>) => {
if (currentMode === 'global') {
for (const key of Object.keys(properties)) {
const value = properties[key as keyof TagPreferences]
await application.setPreference(key as PrefKey, value).catch(console.error)
reloadPreferences()
}
} else {
await application.mutator.changeAndSaveItem<TagMutator>(selectedTag, (mutator) => {
mutator.preferences = {
...mutator.preferences,
...properties,
}
})
}
},
[reloadPreferences, application, currentMode, selectedTag],
)
const resetTagPreferences = useCallback(() => {
application.mutator.changeAndSaveItem<TagMutator>(selectedTag, (mutator) => {
mutator.preferences = undefined
})
}, [application, selectedTag])
const toggleSortReverse = useCallback(() => {
application.setPreference(PrefKey.SortNotesReverse, !sortReverse).catch(console.error)
setSortReverse(!sortReverse)
}, [application, sortReverse])
void changePreferences({ sortReverse: !preferences.sortReverse })
}, [preferences, changePreferences])
const toggleSortBy = useCallback(
(sort: CollectionSortProperty) => {
if (sortBy === sort) {
if (preferences.sortBy === sort) {
toggleSortReverse()
} else {
setSortBy(sort)
application.setPreference(PrefKey.SortNotesBy, sort).catch(console.error)
void changePreferences({ sortBy: sort })
}
},
[application, sortBy, toggleSortReverse],
[preferences, changePreferences, toggleSortReverse],
)
const toggleSortByDateModified = useCallback(() => {
@@ -76,58 +131,115 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
}, [toggleSortBy])
const toggleHidePreview = useCallback(() => {
setHidePreview(!hidePreview)
application.setPreference(PrefKey.NotesHideNotePreview, !hidePreview).catch(console.error)
}, [application, hidePreview])
void changePreferences({ hideNotePreview: !preferences.hideNotePreview })
}, [preferences, changePreferences])
const toggleHideDate = useCallback(() => {
setHideDate(!hideDate)
application.setPreference(PrefKey.NotesHideDate, !hideDate).catch(console.error)
}, [application, hideDate])
void changePreferences({ hideDate: !preferences.hideDate })
}, [preferences, changePreferences])
const toggleHideTags = useCallback(() => {
setHideTags(!hideTags)
application.setPreference(PrefKey.NotesHideTags, !hideTags).catch(console.error)
}, [application, hideTags])
void changePreferences({ hideTags: !preferences.hideTags })
}, [preferences, changePreferences])
const toggleHidePinned = useCallback(() => {
setHidePinned(!hidePinned)
application.setPreference(PrefKey.NotesHidePinned, !hidePinned).catch(console.error)
}, [application, hidePinned])
void changePreferences({ hidePinned: !preferences.hidePinned })
}, [preferences, changePreferences])
const toggleShowArchived = useCallback(() => {
setShowArchived(!showArchived)
application.setPreference(PrefKey.NotesShowArchived, !showArchived).catch(console.error)
}, [application, showArchived])
void changePreferences({ showArchived: !preferences.showArchived })
}, [preferences, changePreferences])
const toggleShowTrashed = useCallback(() => {
setShowTrashed(!showTrashed)
application.setPreference(PrefKey.NotesShowTrashed, !showTrashed).catch(console.error)
}, [application, showTrashed])
void changePreferences({ showTrashed: !preferences.showTrashed })
}, [preferences, changePreferences])
const toggleHideProtected = useCallback(() => {
setHideProtected(!hideProtected)
application.setPreference(PrefKey.NotesHideProtected, !hideProtected).catch(console.error)
}, [application, hideProtected])
void changePreferences({ hideProtected: !preferences.hideProtected })
}, [preferences, changePreferences])
const toggleEditorIcon = useCallback(() => {
setHideEditorIcon(!hideEditorIcon)
application.setPreference(PrefKey.NotesHideEditorIcon, !hideEditorIcon).catch(console.error)
}, [application, hideEditorIcon])
void changePreferences({ hideEditorIcon: !preferences.hideEditorIcon })
}, [preferences, changePreferences])
const TabButton: FunctionComponent<{
label: string
mode: PreferenceMode
icon?: VectorIconNameOrEmoji
}> = ({ mode, label, icon }) => {
const isSelected = currentMode === mode
return (
<button
className={classNames(
'relative cursor-pointer rounded-full border-2 border-solid border-transparent px-2 text-sm focus:shadow-none',
isSelected ? 'bg-info text-info-contrast' : 'bg-transparent text-text hover:bg-info-backdrop',
)}
onClick={() => {
setCurrentMode(mode)
}}
>
<div className="flex items-center justify-center">
{icon && (
<Icon
size="small"
type={icon as IconType}
className={classNames('mr-1 cursor-pointer', isSelected ? 'text-info-contrast' : 'text-neutral')}
/>
)}
<div>{label}</div>
</div>
</button>
)
}
const NoSubscriptionBanner = () => (
<div className="m-2 mt-2 mb-3 grid grid-cols-1 rounded-md border border-border p-4">
<div className="flex items-center">
<Icon className={classNames('mr-1 -ml-1 h-5 w-5', PremiumFeatureIconClass)} type={PremiumFeatureIconName} />
<h1 className="sk-h3 m-0 text-sm font-semibold">Upgrade for per-tag preferences</h1>
</div>
<p className="col-start-1 col-end-3 m-0 mt-1 text-sm">
Create powerful workflows and organizational layouts with per-tag display preferences.
</p>
<Button
primary
small
className="col-start-1 col-end-3 mt-3 justify-self-start uppercase"
onClick={() => application.openPurchaseFlow()}
>
Upgrade Features
</Button>
</div>
)
return (
<Menu className="text-sm" a11yLabel="Notes list options menu" closeMenu={closeDisplayOptionsMenu} isOpen={isOpen}>
<div className="my-1 px-3 text-xs font-semibold uppercase text-text">Preferences for</div>
<div className={classNames('mt-1.5 flex w-full justify-between px-3', !controlsDisabled && 'mb-3')}>
<div className="flex items-center gap-1.5">
<TabButton label="Global" mode="global" />
{!isSystemTag && <TabButton label={selectedTag.title} icon={selectedTag.iconString} mode="tag" />}
</div>
{currentMode === 'tag' && <button onClick={resetTagPreferences}>Reset</button>}
</div>
{controlsDisabled && <NoSubscriptionBanner />}
<MenuItemSeparator />
<div className="my-1 px-3 text-xs font-semibold uppercase text-text">Sort by</div>
<MenuItem
disabled={controlsDisabled}
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByDateModified}
checked={sortBy === CollectionSort.UpdatedAt}
checked={preferences.sortBy === CollectionSort.UpdatedAt}
>
<div className="ml-2 flex flex-grow items-center justify-between">
<span>Date modified</span>
{sortBy === CollectionSort.UpdatedAt ? (
sortReverse ? (
{preferences.sortBy === CollectionSort.UpdatedAt ? (
preferences.sortReverse ? (
<Icon type="arrows-sort-up" className="text-neutral" />
) : (
<Icon type="arrows-sort-down" className="text-neutral" />
@@ -136,15 +248,16 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
</div>
</MenuItem>
<MenuItem
disabled={controlsDisabled}
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByCreationDate}
checked={sortBy === CollectionSort.CreatedAt}
checked={preferences.sortBy === CollectionSort.CreatedAt}
>
<div className="ml-2 flex flex-grow items-center justify-between">
<span>Creation date</span>
{sortBy === CollectionSort.CreatedAt ? (
sortReverse ? (
{preferences.sortBy === CollectionSort.CreatedAt ? (
preferences.sortReverse ? (
<Icon type="arrows-sort-up" className="text-neutral" />
) : (
<Icon type="arrows-sort-down" className="text-neutral" />
@@ -153,15 +266,16 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
</div>
</MenuItem>
<MenuItem
disabled={controlsDisabled}
className="py-2"
type={MenuItemType.RadioButton}
onClick={toggleSortByTitle}
checked={sortBy === CollectionSort.Title}
checked={preferences.sortBy === CollectionSort.Title}
>
<div className="ml-2 flex flex-grow items-center justify-between">
<span>Title</span>
{sortBy === CollectionSort.Title ? (
sortReverse ? (
{preferences.sortBy === CollectionSort.Title ? (
preferences.sortReverse ? (
<Icon type="arrows-sort-up" className="text-neutral" />
) : (
<Icon type="arrows-sort-down" className="text-neutral" />
@@ -173,34 +287,38 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
<div className="px-3 py-1 text-xs font-semibold uppercase text-text">View</div>
{!isFilesSmartView && (
<MenuItem
disabled={controlsDisabled}
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hidePreview}
checked={!preferences.hideNotePreview}
onChange={toggleHidePreview}
>
<div className="max-w-3/4 flex flex-col">Show note preview</div>
</MenuItem>
)}
<MenuItem
disabled={controlsDisabled}
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideDate}
checked={!preferences.hideDate}
onChange={toggleHideDate}
>
Show date
</MenuItem>
<MenuItem
disabled={controlsDisabled}
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideTags}
checked={!preferences.hideTags}
onChange={toggleHideTags}
>
Show tags
</MenuItem>
<MenuItem
disabled={controlsDisabled}
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideEditorIcon}
checked={!preferences.hideEditorIcon}
onChange={toggleEditorIcon}
>
Show icon
@@ -208,37 +326,51 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
<MenuItemSeparator />
<div className="px-3 py-1 text-xs font-semibold uppercase text-text">Other</div>
<MenuItem
disabled={controlsDisabled}
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hidePinned}
checked={!preferences.hidePinned}
onChange={toggleHidePinned}
>
Show pinned
</MenuItem>
<MenuItem
disabled={controlsDisabled}
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={!hideProtected}
checked={!preferences.hideProtected}
onChange={toggleHideProtected}
>
Show protected
</MenuItem>
<MenuItem
disabled={controlsDisabled}
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={showArchived}
checked={preferences.showArchived}
onChange={toggleShowArchived}
>
Show archived
</MenuItem>
<MenuItem
disabled={controlsDisabled}
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={showTrashed}
checked={preferences.showTrashed}
onChange={toggleShowTrashed}
>
Show trashed
</MenuItem>
<MenuItemSeparator />
<NewNotePreferences
disabled={controlsDisabled}
application={application}
selectedTag={selectedTag}
mode={currentMode}
changePreferencesCallback={changePreferences}
/>
</Menu>
)
}

View File

@@ -1,4 +1,5 @@
import { WebApplication } from '@/Application/Application'
import { AnyTag } from '@/Controllers/Navigation/AnyTagType'
export type DisplayOptionsMenuPositionProps = {
top: number
@@ -6,10 +7,8 @@ export type DisplayOptionsMenuPositionProps = {
}
export type DisplayOptionsMenuProps = {
application: {
getPreference: WebApplication['getPreference']
setPreference: WebApplication['setPreference']
}
application: WebApplication
selectedTag: AnyTag
closeDisplayOptionsMenu: () => void
isOpen: boolean
isFilesSmartView: boolean

View File

@@ -0,0 +1,278 @@
import {
ComponentArea,
ComponentMutator,
FeatureIdentifier,
NewNoteTitleFormat,
PrefKey,
SNComponent,
TagPreferences,
} from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { ChangeEventHandler, FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
import { PrefDefaults } from '@/Constants/PrefDefaults'
import Dropdown from '@/Components/Dropdown/Dropdown'
import { DropdownItem } from '@/Components/Dropdown/DropdownItem'
import { WebApplication } from '@/Application/Application'
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
import { AnyTag } from '@/Controllers/Navigation/AnyTagType'
import { PreferenceMode } from './PreferenceMode'
import dayjs from 'dayjs'
const PlainEditorType = 'plain-editor'
type EditorOption = DropdownItem & {
value: FeatureIdentifier | typeof PlainEditorType
}
const PrefChangeDebounceTimeInMs = 25
const HelpPageUrl = 'https://day.js.org/docs/en/display/format#list-of-all-available-formats'
const NoteTitleFormatOptions = [
{
label: 'Current date and time',
value: NewNoteTitleFormat.CurrentDateAndTime,
},
{
label: 'Current note count',
value: NewNoteTitleFormat.CurrentNoteCount,
},
{
label: 'Custom format',
value: NewNoteTitleFormat.CustomFormat,
},
{
label: 'Empty',
value: NewNoteTitleFormat.Empty,
},
]
type Props = {
application: WebApplication
selectedTag: AnyTag
mode: PreferenceMode
changePreferencesCallback: (properties: Partial<TagPreferences>) => Promise<void>
disabled?: boolean
}
const NewNotePreferences: FunctionComponent<Props> = ({
application,
selectedTag,
mode,
changePreferencesCallback,
disabled,
}: Props) => {
const [editorItems, setEditorItems] = useState<DropdownItem[]>([])
const [defaultEditorIdentifier, setDefaultEditorIdentifier] = useState<string>(PlainEditorType)
const [newNoteTitleFormat, setNewNoteTitleFormat] = useState<NewNoteTitleFormat>(
NewNoteTitleFormat.CurrentDateAndTime,
)
const [customNoteTitleFormat, setCustomNoteTitleFormat] = useState('')
const getGlobalEditorDefault = useCallback((): SNComponent | undefined => {
return application.componentManager.componentsForArea(ComponentArea.Editor).filter((e) => e.isDefaultEditor())[0]
}, [application])
const reloadPreferences = useCallback(() => {
if (mode === 'tag' && selectedTag.preferences?.editorIdentifier) {
setDefaultEditorIdentifier(selectedTag.preferences?.editorIdentifier)
} else {
const globalDefault = getGlobalEditorDefault()
setDefaultEditorIdentifier(globalDefault?.identifier || PlainEditorType)
}
if (mode === 'tag' && selectedTag.preferences?.newNoteTitleFormat) {
setNewNoteTitleFormat(selectedTag.preferences?.newNoteTitleFormat)
} else {
setNewNoteTitleFormat(
application.getPreference(PrefKey.NewNoteTitleFormat, PrefDefaults[PrefKey.NewNoteTitleFormat]),
)
}
}, [mode, selectedTag, application, getGlobalEditorDefault, setDefaultEditorIdentifier, setNewNoteTitleFormat])
useEffect(() => {
if (mode === 'tag' && selectedTag.preferences?.customNoteTitleFormat) {
setCustomNoteTitleFormat(selectedTag.preferences?.customNoteTitleFormat)
} else {
setCustomNoteTitleFormat(
application.getPreference(PrefKey.CustomNoteTitleFormat, PrefDefaults[PrefKey.CustomNoteTitleFormat]),
)
}
}, [application, mode, selectedTag])
useEffect(() => {
void reloadPreferences()
}, [reloadPreferences])
const setNewNoteTitleFormatChange = (value: string) => {
setNewNoteTitleFormat(value as NewNoteTitleFormat)
if (mode === 'global') {
application.setPreference(PrefKey.NewNoteTitleFormat, value as NewNoteTitleFormat)
} else {
void changePreferencesCallback({ newNoteTitleFormat: value as NewNoteTitleFormat })
}
}
const removeEditorGlobalDefault = (application: WebApplication, component: SNComponent) => {
application.mutator
.changeAndSaveItem(component, (m) => {
const mutator = m as ComponentMutator
mutator.defaultEditor = false
})
.catch(console.error)
}
const makeEditorGlobalDefault = (
application: WebApplication,
component: SNComponent,
currentDefault?: SNComponent,
) => {
if (currentDefault) {
removeEditorGlobalDefault(application, currentDefault)
}
application.mutator
.changeAndSaveItem(component, (m) => {
const mutator = m as ComponentMutator
mutator.defaultEditor = true
})
.catch(console.error)
}
useEffect(() => {
const editors = application.componentManager
.componentsForArea(ComponentArea.Editor)
.map((editor): EditorOption => {
const identifier = editor.package_info.identifier
const [iconType, tint] = application.iconsController.getIconAndTintForNoteType(editor.package_info.note_type)
return {
label: editor.displayName,
value: identifier,
...(iconType ? { icon: iconType } : null),
...(tint ? { iconClassName: `text-accessory-tint-${tint}` } : null),
}
})
.concat([
{
icon: 'plain-text',
iconClassName: 'text-accessory-tint-1',
label: PLAIN_EDITOR_NAME,
value: PlainEditorType,
},
])
.sort((a, b) => {
return a.label.toLowerCase() < b.label.toLowerCase() ? -1 : 1
})
setEditorItems(editors)
}, [application])
const setDefaultEditor = (value: string) => {
setDefaultEditorIdentifier(value as FeatureIdentifier)
if (mode === 'global') {
const editors = application.componentManager.componentsForArea(ComponentArea.Editor)
const currentDefault = getGlobalEditorDefault()
if (value !== PlainEditorType) {
const editorComponent = editors.filter((e) => e.package_info.identifier === value)[0]
makeEditorGlobalDefault(application, editorComponent, currentDefault)
} else if (currentDefault) {
removeEditorGlobalDefault(application, currentDefault)
}
} else {
void changePreferencesCallback({ editorIdentifier: value })
}
}
const debounceTimeoutRef = useRef<number>()
const handleCustomFormatInputChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const newFormat = event.currentTarget.value
setCustomNoteTitleFormat(newFormat)
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
}
debounceTimeoutRef.current = window.setTimeout(async () => {
if (mode === 'tag') {
void changePreferencesCallback({ customNoteTitleFormat: newFormat })
} else {
application.setPreference(PrefKey.CustomNoteTitleFormat, newFormat)
}
}, PrefChangeDebounceTimeInMs)
}
return (
<div className="my-1 px-3 pb-2 pt-1">
<div className="text-xs font-semibold uppercase text-text">New Note Defaults</div>
<div>
<div className="mt-3">Note Type</div>
<div className="mt-2">
<Dropdown
portal={false}
disabled={disabled}
fullWidth={true}
id="def-editor-dropdown"
label="Select the default note type"
items={editorItems}
value={defaultEditorIdentifier}
onChange={setDefaultEditor}
/>
</div>
</div>
<div>
<div className="mt-3">Title Format</div>
<div className="mt-2">
<Dropdown
portal={false}
disabled={disabled}
fullWidth={true}
id="def-new-note-title-format"
label="Select the default note type"
items={NoteTitleFormatOptions}
value={newNoteTitleFormat}
onChange={setNewNoteTitleFormatChange}
/>
</div>
</div>
{newNoteTitleFormat === NewNoteTitleFormat.CustomFormat && (
<div className="mt-2">
<div className="mt-2">
<input
disabled={disabled}
className="w-full min-w-55 rounded border border-solid border-passive-3 bg-default px-2 py-1.5 text-sm focus-within:ring-2 focus-within:ring-info"
placeholder="e.g. YYYY-MM-DD"
value={customNoteTitleFormat}
onChange={handleCustomFormatInputChange}
spellCheck={false}
/>
</div>
<div className="mt-2">
<span className="font-bold">Preview:</span> {dayjs().format(customNoteTitleFormat)}
</div>
<div className="mt-1">
<a
className="underline"
href={HelpPageUrl}
rel="noreferrer"
target="_blank"
onClick={(event) => {
if (application.isNativeMobileWeb()) {
event.preventDefault()
application.mobileDevice().openUrl(HelpPageUrl)
}
}}
>
Options
</a>
. Use <code>[]</code> to escape date-time formatting.
</div>
</div>
)}
</div>
)
}
export default observer(NewNotePreferences)

View File

@@ -0,0 +1 @@
export type PreferenceMode = 'global' | 'tag'

View File

@@ -15,6 +15,8 @@ type DropdownProps = {
onChange: (value: string, item: DropdownItem) => void
disabled?: boolean
className?: string
fullWidth?: boolean
portal?: boolean
}
type ListboxButtonProps = DropdownItem & {
@@ -42,7 +44,17 @@ const CustomDropdownButton: FunctionComponent<ListboxButtonProps> = ({
</>
)
const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, onChange, disabled, className }) => {
const Dropdown: FunctionComponent<DropdownProps> = ({
id,
label,
items,
value,
onChange,
disabled,
className,
fullWidth,
portal = true,
}) => {
const labelId = `${id}-label`
const handleChange = (value: string) => {
@@ -56,6 +68,7 @@ const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, o
<VisuallyHidden id={labelId}>{label}</VisuallyHidden>
<ListboxInput value={value} onChange={handleChange} aria-labelledby={labelId} disabled={disabled}>
<StyledListboxButton
className={`w-full ${!fullWidth ? 'md:w-fit' : ''}`}
children={({ value, label, isExpanded }) => {
const current = items.find((item) => item.value === value)
const icon = current ? current?.icon : null
@@ -69,7 +82,7 @@ const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, o
})
}}
/>
<ListboxPopover className="sn-dropdown sn-dropdown-popover">
<ListboxPopover portal={portal} className="sn-dropdown sn-dropdown-popover">
<div className="sn-component">
<ListboxList>
{items.map((item) => (

View File

@@ -1,9 +1,7 @@
import { ListboxButton } from '@reach/listbox'
import styled from 'styled-components'
const StyledListboxButton = styled(ListboxButton).attrs(() => ({
className: 'w-full md:w-fit',
}))`
const StyledListboxButton = styled(ListboxButton)`
&[data-reach-listbox-button] {
background-color: var(--sn-stylekit-background-color);
border-radius: 0.25rem;

View File

@@ -93,6 +93,7 @@ const IconPicker = ({ selectedValue, onIconChange, platform, className }: Props)
<div className={'mt-2 h-full min-h-0 overflow-auto'}>
{currentType === 'icon' && (
<Dropdown
fullWidth={true}
id="change-tag-icon-dropdown"
label="Change the icon for a tag"
items={iconOptions}

View File

@@ -19,6 +19,7 @@ type MenuItemProps = {
icon?: IconType
iconClassName?: string
tabIndex?: number
disabled?: boolean
}
const MenuItem = forwardRef(
@@ -34,12 +35,14 @@ const MenuItem = forwardRef(
icon,
iconClassName,
tabIndex,
disabled,
}: MenuItemProps,
ref: Ref<HTMLButtonElement>,
) => {
return type === MenuItemType.SwitchButton && typeof onChange === 'function' ? (
<li className="list-none" role="none">
<button
disabled={disabled}
ref={ref}
className={classNames(
'flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5',
@@ -54,12 +57,13 @@ const MenuItem = forwardRef(
aria-checked={checked}
>
<span className="flex flex-grow items-center">{children}</span>
<Switch className="px-0" checked={checked} />
<Switch disabled={disabled} className="px-0" checked={checked} />
</button>
</li>
) : (
<li className="list-none" role="none">
<button
disabled={disabled}
ref={ref}
role={type === MenuItemType.RadioButton ? 'menuitemradio' : 'menuitem'}
tabIndex={typeof tabIndex === 'number' ? tabIndex : FOCUSABLE_BUT_NOT_TABBABLE}
@@ -75,7 +79,7 @@ const MenuItem = forwardRef(
>
{type === MenuItemType.IconButton && icon ? <Icon type={icon} className={iconClassName} /> : null}
{type === MenuItemType.RadioButton && typeof checked === 'boolean' ? (
<RadioIndicator checked={checked} className="flex-shrink-0" />
<RadioIndicator disabled={disabled} checked={checked} className="flex-shrink-0" />
) : null}
{children}
</button>

View File

@@ -29,7 +29,12 @@ const NoAccountWarningContent = ({ accountMenuController, noAccountWarningContro
<p className="col-start-1 col-end-3 m-0 mt-1 text-sm">
Sign in or register to sync your notes to your other devices with end-to-end encryption.
</p>
<Button primary small className="col-start-1 col-end-3 mt-3 justify-self-start" onClick={showAccountMenu}>
<Button
primary
small
className="col-start-1 col-end-3 mt-3 justify-self-start uppercase"
onClick={showAccountMenu}
>
Open Account menu
</Button>
<button

View File

@@ -1,58 +1,17 @@
import Dropdown from '@/Components/Dropdown/Dropdown'
import { DropdownItem } from '@/Components/Dropdown/DropdownItem'
import {
FeatureIdentifier,
PrefKey,
ComponentArea,
ComponentMutator,
SNComponent,
NewNoteTitleFormat,
Platform,
} from '@standardnotes/snjs'
import { PrefKey, Platform } from '@standardnotes/snjs'
import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import { WebApplication } from '@/Application/Application'
import { FunctionComponent, useEffect, useMemo, useState } from 'react'
import { FunctionComponent, useState } from 'react'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import Switch from '@/Components/Switch/Switch'
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
import CustomNoteTitleFormat from './Defaults/CustomNoteTitleFormat'
import { PrefDefaults } from '@/Constants/PrefDefaults'
type Props = {
application: WebApplication
}
type EditorOption = DropdownItem & {
value: FeatureIdentifier | 'plain-editor'
}
const makeEditorDefault = (application: WebApplication, component: SNComponent, currentDefault: SNComponent) => {
if (currentDefault) {
removeEditorDefault(application, currentDefault)
}
application.mutator
.changeAndSaveItem(component, (m) => {
const mutator = m as ComponentMutator
mutator.defaultEditor = true
})
.catch(console.error)
}
const removeEditorDefault = (application: WebApplication, component: SNComponent) => {
application.mutator
.changeAndSaveItem(component, (m) => {
const mutator = m as ComponentMutator
mutator.defaultEditor = false
})
.catch(console.error)
}
const getDefaultEditor = (application: WebApplication) => {
return application.componentManager.componentsForArea(ComponentArea.Editor).filter((e) => e.isDefaultEditor())[0]
}
export const AndroidConfirmBeforeExitKey = 'ConfirmBeforeExit'
const Defaults: FunctionComponent<Props> = ({ application }) => {
@@ -60,23 +19,10 @@ const Defaults: FunctionComponent<Props> = ({ application }) => {
() => (application.getValue(AndroidConfirmBeforeExitKey) as boolean) ?? true,
)
const [editorItems, setEditorItems] = useState<DropdownItem[]>([])
const [defaultEditorValue, setDefaultEditorValue] = useState(
() => getDefaultEditor(application)?.package_info?.identifier || 'plain-editor',
)
const [spellcheck, setSpellcheck] = useState(() =>
application.getPreference(PrefKey.EditorSpellcheck, PrefDefaults[PrefKey.EditorSpellcheck]),
)
const [newNoteTitleFormat, setNewNoteTitleFormat] = useState(() =>
application.getPreference(PrefKey.NewNoteTitleFormat, PrefDefaults[PrefKey.NewNoteTitleFormat]),
)
const handleNewNoteTitleFormatChange = (value: string) => {
setNewNoteTitleFormat(value as NewNoteTitleFormat)
application.setPreference(PrefKey.NewNoteTitleFormat, value as NewNoteTitleFormat)
}
const [addNoteToParentFolders, setAddNoteToParentFolders] = useState(() =>
application.getPreference(PrefKey.NoteAddToParentFolders, PrefDefaults[PrefKey.NoteAddToParentFolders]),
)
@@ -86,70 +32,6 @@ const Defaults: FunctionComponent<Props> = ({ application }) => {
application.toggleGlobalSpellcheck().catch(console.error)
}
useEffect(() => {
const editors = application.componentManager
.componentsForArea(ComponentArea.Editor)
.map((editor): EditorOption => {
const identifier = editor.package_info.identifier
const [iconType, tint] = application.iconsController.getIconAndTintForNoteType(editor.package_info.note_type)
return {
label: editor.displayName,
value: identifier,
...(iconType ? { icon: iconType } : null),
...(tint ? { iconClassName: `text-accessory-tint-${tint}` } : null),
}
})
.concat([
{
icon: 'plain-text',
iconClassName: 'text-accessory-tint-1',
label: PLAIN_EDITOR_NAME,
value: 'plain-editor',
},
])
.sort((a, b) => {
return a.label.toLowerCase() < b.label.toLowerCase() ? -1 : 1
})
setEditorItems(editors)
}, [application])
const setDefaultEditor = (value: string) => {
setDefaultEditorValue(value as FeatureIdentifier)
const editors = application.componentManager.componentsForArea(ComponentArea.Editor)
const currentDefault = getDefaultEditor(application)
if (value !== 'plain-editor') {
const editorComponent = editors.filter((e) => e.package_info.identifier === value)[0]
makeEditorDefault(application, editorComponent, currentDefault)
} else {
removeEditorDefault(application, currentDefault)
}
}
const noteTitleFormatOptions = useMemo(
() => [
{
label: 'Current date and time',
value: NewNoteTitleFormat.CurrentDateAndTime,
},
{
label: 'Current note count',
value: NewNoteTitleFormat.CurrentNoteCount,
},
{
label: 'Custom format',
value: NewNoteTitleFormat.CustomFormat,
},
{
label: 'Empty',
value: NewNoteTitleFormat.Empty,
},
],
[],
)
const toggleAndroidConfirmBeforeExit = () => {
const newValue = !androidConfirmBeforeExit
setAndroidConfirmBeforeExit(newValue)
@@ -161,44 +43,17 @@ const Defaults: FunctionComponent<Props> = ({ application }) => {
<PreferencesSegment>
<Title>Defaults</Title>
{application.platform === Platform.Android && (
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Always ask before closing app</Subtitle>
<Text>Whether a confirmation dialog should be shown before closing the app.</Text>
<>
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Always ask before closing app (Android)</Subtitle>
<Text>Whether a confirmation dialog should be shown before closing the app.</Text>
</div>
<Switch onChange={toggleAndroidConfirmBeforeExit} checked={androidConfirmBeforeExit} />
</div>
<Switch onChange={toggleAndroidConfirmBeforeExit} checked={androidConfirmBeforeExit} />
</div>
<HorizontalSeparator classes="my-4" />
</>
)}
<HorizontalSeparator classes="my-4" />
<div>
<Subtitle>Default Note Type</Subtitle>
<Text>New notes will be created using this type</Text>
<div className="mt-2">
<Dropdown
id="def-editor-dropdown"
label="Select the default note type"
items={editorItems}
value={defaultEditorValue}
onChange={setDefaultEditor}
/>
</div>
</div>
<HorizontalSeparator classes="my-4" />
<div>
<Subtitle>Default Note Title Format</Subtitle>
<Text>New notes will be created with a title in this format</Text>
<div className="mt-2">
<Dropdown
id="def-new-note-title-format"
label="Select the default note type"
items={noteTitleFormatOptions}
value={newNoteTitleFormat}
onChange={handleNewNoteTitleFormatChange}
/>
</div>
</div>
{newNoteTitleFormat === NewNoteTitleFormat.CustomFormat && <CustomNoteTitleFormat application={application} />}
<HorizontalSeparator classes="my-4" />
<div className="flex items-center justify-between">
<div className="flex flex-col">
<Subtitle>Spellcheck</Subtitle>

View File

@@ -1,80 +0,0 @@
import { WebApplication } from '@/Application/Application'
import { Text, Subtitle } from '@/Components/Preferences/PreferencesComponents/Content'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import { PrefDefaults } from '@/Constants/PrefDefaults'
import { PrefKey } from '@standardnotes/snjs'
import { ChangeEventHandler, useRef, useState } from 'react'
import dayjs from 'dayjs'
type Props = {
application: WebApplication
}
const PrefChangeDebounceTimeInMs = 25
const HelpPageUrl = 'https://day.js.org/docs/en/display/format#list-of-all-available-formats'
const CustomNoteTitleFormat = ({ application }: Props) => {
const [customNoteTitleFormat, setCustomNoteTitleFormat] = useState(() =>
application.getPreference(PrefKey.CustomNoteTitleFormat, PrefDefaults[PrefKey.CustomNoteTitleFormat]),
)
const setCustomNoteTitleFormatPreference = () => {
application.setPreference(PrefKey.CustomNoteTitleFormat, customNoteTitleFormat)
}
const debounceTimeoutRef = useRef<number>()
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (event) => {
setCustomNoteTitleFormat(event.currentTarget.value)
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current)
}
debounceTimeoutRef.current = window.setTimeout(async () => {
setCustomNoteTitleFormatPreference()
}, PrefChangeDebounceTimeInMs)
}
return (
<>
<HorizontalSeparator classes="my-4" />
<div>
<Subtitle>Custom Note Title Format</Subtitle>
<Text>
All available date-time formatting options can be found{' '}
<a
className="underline"
href={HelpPageUrl}
target="_blank"
onClick={(event) => {
if (application.isNativeMobileWeb()) {
event.preventDefault()
application.mobileDevice().openUrl(HelpPageUrl)
}
}}
>
here
</a>
. Use square brackets (<code>[]</code>) to escape date-time formatting.
</Text>
<div className="mt-2">
<input
className="min-w-55 rounded border border-solid border-passive-3 bg-default px-2 py-1.5 text-sm focus-within:ring-2 focus-within:ring-info"
placeholder="e.g. YYYY-MM-DD"
value={customNoteTitleFormat}
onChange={handleInputChange}
onBlur={setCustomNoteTitleFormatPreference}
spellCheck={false}
/>
</div>
<div className="mt-2">
<span className="font-bold">Preview:</span> {dayjs().format(customNoteTitleFormat)}
</div>
</div>
</>
)
}
export default CustomNoteTitleFormat

View File

@@ -1,11 +1,12 @@
type Props = {
checked: boolean
className?: string
disabled?: boolean
}
const RadioIndicator = ({ checked, className }: Props) => (
const RadioIndicator = ({ checked, className, disabled }: Props) => (
<div
className={`relative h-4 w-4 rounded-full border-2 border-solid ${
className={`relative h-4 w-4 rounded-full border-2 border-solid ${disabled ? 'opacity-50' : ''} ${
checked
? 'border-info after:absolute after:top-1/2 after:left-1/2 after:h-2 after:w-2 after:-translate-x-1/2 after:-translate-y-1/2 after:rounded-full after:bg-info'
: 'border-passive-1'

View File

@@ -19,7 +19,7 @@ export const PrefDefaults = {
[PrefKey.NotesHideProtected]: false,
[PrefKey.NotesHideNotePreview]: false,
[PrefKey.NotesHideDate]: false,
[PrefKey.NotesHideTags]: true,
[PrefKey.NotesHideTags]: false,
[PrefKey.NotesHideEditorIcon]: false,
[PrefKey.UseSystemColorScheme]: false,
[PrefKey.AutoLightThemeIdentifier]: 'Default',

View File

@@ -19,6 +19,7 @@ import {
FileItem,
WebAppEvent,
NewNoteTitleFormat,
useBoolean,
} from '@standardnotes/snjs'
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
import { WebApplication } from '../../Application/Application'
@@ -121,12 +122,23 @@ export class ItemListController
}),
)
this.disposers.push(
reaction(
() => [this.navigationController.selected],
() => {
void this.reloadDisplayPreferences()
},
),
)
this.disposers.push(
application.streamItems<SNTag>([ContentType.Tag], async ({ changed, inserted }) => {
const tags = [...changed, ...inserted]
/** A tag could have changed its relationships, so we need to reload the filter */
this.reloadNotesDisplayOptions()
const { didReloadItems } = await this.reloadDisplayPreferences()
if (!didReloadItems) {
/** A tag could have changed its relationships, so we need to reload the filter */
this.reloadNotesDisplayOptions()
}
void this.reloadItems(ItemsReloadSource.ItemStream)
@@ -139,7 +151,7 @@ export class ItemListController
this.disposers.push(
application.addEventObserver(async () => {
void this.reloadPreferences()
void this.reloadDisplayPreferences()
}, ApplicationEvent.PreferencesChanged),
)
@@ -204,7 +216,7 @@ export class ItemListController
reloadItems: action,
reloadPanelTitle: action,
reloadPreferences: action,
reloadDisplayPreferences: action,
resetPagination: action,
setCompletedFullSync: action,
setNoteFilterText: action,
@@ -447,54 +459,66 @@ export class ItemListController
this.application.items.setPrimaryItemDisplayOptions(criteria)
}
reloadPreferences = async () => {
reloadDisplayPreferences = async (): Promise<{ didReloadItems: boolean }> => {
const newDisplayOptions = {} as DisplayOptions
const newWebDisplayOptions = {} as WebDisplayOptions
const selectedTag = this.navigationController.selected
const currentSortBy = this.displayOptions.sortBy
let sortBy = this.application.getPreference(PrefKey.SortNotesBy, PrefDefaults[PrefKey.SortNotesBy])
let sortBy =
selectedTag?.preferences?.sortBy ||
this.application.getPreference(PrefKey.SortNotesBy, PrefDefaults[PrefKey.SortNotesBy])
if (sortBy === CollectionSort.UpdatedAt || (sortBy as string) === 'client_updated_at') {
sortBy = CollectionSort.UpdatedAt
}
newDisplayOptions.sortBy = sortBy
newDisplayOptions.sortDirection =
this.application.getPreference(PrefKey.SortNotesReverse, PrefDefaults[PrefKey.SortNotesReverse]) === false
useBoolean(
selectedTag?.preferences?.sortReverse,
this.application.getPreference(PrefKey.SortNotesReverse, PrefDefaults[PrefKey.SortNotesReverse]),
) === false
? 'dsc'
: 'asc'
newDisplayOptions.includeArchived = this.application.getPreference(
PrefKey.NotesShowArchived,
PrefDefaults[PrefKey.NotesShowArchived],
)
newDisplayOptions.includeTrashed = this.application.getPreference(
PrefKey.NotesShowTrashed,
PrefDefaults[PrefKey.NotesShowTrashed],
) as boolean
newDisplayOptions.includePinned = !this.application.getPreference(
PrefKey.NotesHidePinned,
PrefDefaults[PrefKey.NotesHidePinned],
)
newDisplayOptions.includeProtected = !this.application.getPreference(
PrefKey.NotesHideProtected,
PrefDefaults[PrefKey.NotesHideProtected],
newDisplayOptions.includeArchived = useBoolean(
selectedTag?.preferences?.showArchived,
this.application.getPreference(PrefKey.NotesShowArchived, PrefDefaults[PrefKey.NotesShowArchived]),
)
newWebDisplayOptions.hideNotePreview = this.application.getPreference(
PrefKey.NotesHideNotePreview,
PrefDefaults[PrefKey.NotesHideNotePreview],
newDisplayOptions.includeTrashed = useBoolean(
selectedTag?.preferences?.showTrashed,
this.application.getPreference(PrefKey.NotesShowTrashed, PrefDefaults[PrefKey.NotesShowTrashed]),
)
newWebDisplayOptions.hideDate = this.application.getPreference(
PrefKey.NotesHideDate,
PrefDefaults[PrefKey.NotesHideDate],
newDisplayOptions.includePinned = useBoolean(
!selectedTag?.preferences?.hidePinned,
!this.application.getPreference(PrefKey.NotesHidePinned, PrefDefaults[PrefKey.NotesHidePinned]),
)
newWebDisplayOptions.hideTags = this.application.getPreference(
PrefKey.NotesHideTags,
PrefDefaults[PrefKey.NotesHideTags],
newDisplayOptions.includeProtected = useBoolean(
!selectedTag?.preferences?.hideProtected,
!this.application.getPreference(PrefKey.NotesHideProtected, PrefDefaults[PrefKey.NotesHideProtected]),
)
newWebDisplayOptions.hideEditorIcon = this.application.getPreference(
PrefKey.NotesHideEditorIcon,
PrefDefaults[PrefKey.NotesHideEditorIcon],
newWebDisplayOptions.hideNotePreview = useBoolean(
selectedTag?.preferences?.hideNotePreview,
this.application.getPreference(PrefKey.NotesHideNotePreview, PrefDefaults[PrefKey.NotesHideNotePreview]),
)
newWebDisplayOptions.hideDate = useBoolean(
selectedTag?.preferences?.hideDate,
this.application.getPreference(PrefKey.NotesHideDate, PrefDefaults[PrefKey.NotesHideDate]),
)
newWebDisplayOptions.hideTags = useBoolean(
selectedTag?.preferences?.hideTags,
this.application.getPreference(PrefKey.NotesHideTags, PrefDefaults[PrefKey.NotesHideTags]),
)
newWebDisplayOptions.hideEditorIcon = useBoolean(
selectedTag?.preferences?.hideEditorIcon,
this.application.getPreference(PrefKey.NotesHideEditorIcon, PrefDefaults[PrefKey.NotesHideEditorIcon]),
)
const displayOptionsChanged =
@@ -518,12 +542,10 @@ export class ItemListController
}
if (!displayOptionsChanged) {
return
return { didReloadItems: false }
}
if (displayOptionsChanged) {
this.reloadNotesDisplayOptions()
}
this.reloadNotesDisplayOptions()
await this.reloadItems(ItemsReloadSource.DisplayOptionsChange)
@@ -535,6 +557,8 @@ export class ItemListController
type: CrossControllerEvent.RequestValuePersistence,
payload: undefined,
})
return { didReloadItems: true }
}
async createNewNoteController(title?: string) {
@@ -555,20 +579,18 @@ export class ItemListController
await this.navigationController.selectHomeNavigationView()
}
const titleFormat = this.application.getPreference(
PrefKey.NewNoteTitleFormat,
PrefDefaults[PrefKey.NewNoteTitleFormat],
)
const titleFormat =
this.navigationController.selected?.preferences?.newNoteTitleFormat ||
this.application.getPreference(PrefKey.NewNoteTitleFormat, PrefDefaults[PrefKey.NewNoteTitleFormat])
let title = formatDateAndTimeForNote(new Date())
if (titleFormat === NewNoteTitleFormat.CurrentNoteCount) {
title = `Note ${this.notes.length + 1}`
} else if (titleFormat === NewNoteTitleFormat.CustomFormat) {
const customFormat = this.application.getPreference(
PrefKey.CustomNoteTitleFormat,
PrefDefaults[PrefKey.CustomNoteTitleFormat],
)
const customFormat =
this.navigationController.selected?.preferences?.customNoteTitleFormat ||
this.application.getPreference(PrefKey.CustomNoteTitleFormat, PrefDefaults[PrefKey.CustomNoteTitleFormat])
title = dayjs().format(customFormat)
} else if (titleFormat === NewNoteTitleFormat.Empty) {
title = ''

View File

@@ -479,6 +479,10 @@ export class NavigationController
}
public setExpanded(tag: SNTag, expanded: boolean) {
if (tag.expanded === expanded) {
return
}
this.application.mutator
.changeAndSaveItem<TagMutator>(tag, (mutator) => {
mutator.expanded = expanded

View File

@@ -110,6 +110,10 @@ export class SubscriptionController extends AbstractViewController {
return Boolean(this.userSubscription?.cancelled)
}
hasValidSubscription(): boolean {
return this.userSubscription != undefined && !this.isUserSubscriptionExpired && !this.isUserSubscriptionCanceled
}
get usedInvitationsCount(): number {
return (
this.subscriptionInvitations?.filter((invitation) =>

View File

@@ -34,11 +34,7 @@ const PremiumModalProvider: FunctionComponent<Props> = observer(
const showModal = !!featureName
const hasSubscription = Boolean(
viewControllerManager.subscriptionController.userSubscription &&
!viewControllerManager.subscriptionController.isUserSubscriptionExpired &&
!viewControllerManager.subscriptionController.isUserSubscriptionCanceled,
)
const hasSubscription = application.hasValidSubscription()
const hasAccount = application.hasAccount()