diff --git a/packages/models/src/Domain/Runtime/Display/ItemDisplayController.spec.ts b/packages/models/src/Domain/Runtime/Display/ItemDisplayController.spec.ts index f46fbd32b..9c386883e 100644 --- a/packages/models/src/Domain/Runtime/Display/ItemDisplayController.spec.ts +++ b/packages/models/src/Domain/Runtime/Display/ItemDisplayController.spec.ts @@ -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) diff --git a/packages/models/src/Domain/Syncable/SmartView/SmartView.ts b/packages/models/src/Domain/Syncable/SmartView/SmartView.ts index 315c12e43..10e092d6f 100644 --- a/packages/models/src/Domain/Syncable/SmartView/SmartView.ts +++ b/packages/models/src/Domain/Syncable/SmartView/SmartView.ts @@ -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 { public readonly predicate!: PredicateInterface public readonly title: string public readonly iconString: IconType | EmojiString + public readonly preferences?: TagPreferences constructor(payload: DecryptedPayloadInterface) { super(payload) @@ -28,6 +34,8 @@ export class SmartView extends DecryptedItem { 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) { diff --git a/packages/models/src/Domain/Syncable/Tag/Tag.spec.ts b/packages/models/src/Domain/Syncable/Tag/Tag.spec.ts index 36d8cd401..90074601c 100644 --- a/packages/models/src/Domain/Syncable/Tag/Tag.spec.ts +++ b/packages/models/src/Domain/Syncable/Tag/Tag.spec.ts @@ -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() + }) }) diff --git a/packages/models/src/Domain/Syncable/Tag/Tag.ts b/packages/models/src/Domain/Syncable/Tag/Tag.ts index 3febe4418..6213e2391 100644 --- a/packages/models/src/Domain/Syncable/Tag/Tag.ts +++ b/packages/models/src/Domain/Syncable/Tag/Tag.ts @@ -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 implements TagContentSpecia public readonly title: string public readonly iconString: VectorIconNameOrEmoji public readonly expanded: boolean + public readonly preferences?: TagPreferences constructor(payload: DecryptedPayloadInterface) { 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[] { diff --git a/packages/models/src/Domain/Syncable/Tag/TagContent.ts b/packages/models/src/Domain/Syncable/Tag/TagContent.ts index 43ca3ce0d..9c3bc269f 100644 --- a/packages/models/src/Domain/Syncable/Tag/TagContent.ts +++ b/packages/models/src/Domain/Syncable/Tag/TagContent.ts @@ -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 diff --git a/packages/models/src/Domain/Syncable/Tag/TagMutator.spec.ts b/packages/models/src/Domain/Syncable/Tag/TagMutator.spec.ts index 9dff7323c..adefc13a0 100644 --- a/packages/models/src/Domain/Syncable/Tag/TagMutator.spec.ts +++ b/packages/models/src/Domain/Syncable/Tag/TagMutator.spec.ts @@ -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() + }) }) diff --git a/packages/models/src/Domain/Syncable/Tag/TagMutator.ts b/packages/models/src/Domain/Syncable/Tag/TagMutator.ts index a1f8c7ab0..67420c4ad 100644 --- a/packages/models/src/Domain/Syncable/Tag/TagMutator.ts +++ b/packages/models/src/Domain/Syncable/Tag/TagMutator.ts @@ -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 { + private mutablePreferences?: TagPreferences + + constructor(item: DecryptedItemInterface, 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 { 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)) diff --git a/packages/models/src/Domain/Syncable/Tag/TagPreferences.ts b/packages/models/src/Domain/Syncable/Tag/TagPreferences.ts new file mode 100644 index 000000000..25eb5053f --- /dev/null +++ b/packages/models/src/Domain/Syncable/Tag/TagPreferences.ts @@ -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 +} diff --git a/packages/models/src/Domain/Syncable/Tag/index.ts b/packages/models/src/Domain/Syncable/Tag/index.ts index 579aa9788..7b53a5d21 100644 --- a/packages/models/src/Domain/Syncable/Tag/index.ts +++ b/packages/models/src/Domain/Syncable/Tag/index.ts @@ -1,3 +1,4 @@ export * from './Tag' export * from './TagMutator' export * from './TagContent' +export * from './TagPreferences' diff --git a/packages/models/src/Domain/Utilities/Test/SpecUtils.ts b/packages/models/src/Domain/Utilities/Test/SpecUtils.ts index cd98410df..ecffe7cb5 100644 --- a/packages/models/src/Domain/Utilities/Test/SpecUtils.ts +++ b/packages/models/src/Domain/Utilities/Test/SpecUtils.ts @@ -41,7 +41,21 @@ export const createNoteWithContent = (content: Partial, createdAt?: ) } -export const createTag = (title = 'photos') => { +export const createTagWithContent = (content: Partial): SNTag => { + return new SNTag( + new DecryptedPayload( + { + uuid: mockUuid(), + content_type: ContentType.Tag, + content: FillItemContent(content), + ...PayloadTimestampDefaults(), + }, + PayloadSource.Constructor, + ), + ) +} + +export const createTagWithTitle = (title = 'photos') => { return new SNTag( new DecryptedPayload( { diff --git a/packages/services/src/Domain/Component/ComponentManagerInterface.ts b/packages/services/src/Domain/Component/ComponentManagerInterface.ts index fa4ba18f8..1ca330c45 100644 --- a/packages/services/src/Domain/Component/ComponentManagerInterface.ts +++ b/packages/services/src/Domain/Component/ComponentManagerInterface.ts @@ -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 } diff --git a/packages/snjs/lib/Client/NoteViewController.ts b/packages/snjs/lib/Client/NoteViewController.ts index 2a3e8eb7a..ed28f5b2b 100644 --- a/packages/snjs/lib/Client/NoteViewController.ts +++ b/packages/snjs/lib/Client/NoteViewController.ts @@ -35,7 +35,8 @@ export class NoteViewController implements ItemViewControllerInterface { public isTemplateNote = false private saveTimeout?: ReturnType 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 { 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(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) } diff --git a/packages/web/src/javascripts/Application/Application.ts b/packages/web/src/javascripts/Application/Application.ts index feb5e72d6..d2da77961 100644 --- a/packages/web/src/javascripts/Application/Application.ts +++ b/packages/web/src/javascripts/Application/Application.ts @@ -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() + } } diff --git a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx index ae03cf344..36fef15cc 100644 --- a/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/ContentListView.tsx @@ -260,15 +260,18 @@ const ContentListView: FunctionComponent = ({ } }} /> - + {selectedTag && ( + + )} void isFilesSmartView: boolean optionsSubtitle?: string + selectedTag: AnyTag } const ContentListHeader = ({ @@ -30,6 +28,7 @@ const ContentListHeader = ({ addNewItem, isFilesSmartView, optionsSubtitle, + selectedTag, }: Props) => { const displayOptionsContainerRef = useRef(null) const displayOptionsButtonRef = useRef(null) @@ -79,6 +78,7 @@ const ContentListHeader = ({ closeDisplayOptionsMenu={toggleDisplayOptionsMenu} isFilesSmartView={isFilesSmartView} isOpen={showDisplayOptionsMenu} + selectedTag={selectedTag} /> diff --git a/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx b/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx index acfa7bf80..985307324 100644 --- a/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenu.tsx @@ -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 = ({ 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(selectedTag.preferences ? 'tag' : 'global') + const [preferences, setPreferences] = useState({}) + 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) => { + 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(selectedTag, (mutator) => { + mutator.preferences = { + ...mutator.preferences, + ...properties, + } + }) + } + }, + [reloadPreferences, application, currentMode, selectedTag], ) + const resetTagPreferences = useCallback(() => { + application.mutator.changeAndSaveItem(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 = ({ }, [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 ( + + ) + } + + const NoSubscriptionBanner = () => ( +
+
+ +

Upgrade for per-tag preferences

+
+

+ Create powerful workflows and organizational layouts with per-tag display preferences. +

+ +
+ ) return ( +
Preferences for
+
+
+ + {!isSystemTag && } +
+ {currentMode === 'tag' && } +
+ + {controlsDisabled && } + + +
Sort by
Date modified - {sortBy === CollectionSort.UpdatedAt ? ( - sortReverse ? ( + {preferences.sortBy === CollectionSort.UpdatedAt ? ( + preferences.sortReverse ? ( ) : ( @@ -136,15 +248,16 @@ const DisplayOptionsMenu: FunctionComponent = ({
Creation date - {sortBy === CollectionSort.CreatedAt ? ( - sortReverse ? ( + {preferences.sortBy === CollectionSort.CreatedAt ? ( + preferences.sortReverse ? ( ) : ( @@ -153,15 +266,16 @@ const DisplayOptionsMenu: FunctionComponent = ({
Title - {sortBy === CollectionSort.Title ? ( - sortReverse ? ( + {preferences.sortBy === CollectionSort.Title ? ( + preferences.sortReverse ? ( ) : ( @@ -173,34 +287,38 @@ const DisplayOptionsMenu: FunctionComponent = ({
View
{!isFilesSmartView && (
Show note preview
)} Show date Show tags Show icon @@ -208,37 +326,51 @@ const DisplayOptionsMenu: FunctionComponent = ({
Other
Show pinned Show protected Show archived Show trashed + + + +
) } diff --git a/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenuProps.tsx b/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenuProps.tsx index dc52d2f1c..2f0665c39 100644 --- a/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenuProps.tsx +++ b/packages/web/src/javascripts/Components/ContentListView/Header/DisplayOptionsMenuProps.tsx @@ -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 diff --git a/packages/web/src/javascripts/Components/ContentListView/Header/NewNotePreferences.tsx b/packages/web/src/javascripts/Components/ContentListView/Header/NewNotePreferences.tsx new file mode 100644 index 000000000..a7480c5b8 --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Header/NewNotePreferences.tsx @@ -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) => Promise + disabled?: boolean +} + +const NewNotePreferences: FunctionComponent = ({ + application, + selectedTag, + mode, + changePreferencesCallback, + disabled, +}: Props) => { + const [editorItems, setEditorItems] = useState([]) + const [defaultEditorIdentifier, setDefaultEditorIdentifier] = useState(PlainEditorType) + const [newNoteTitleFormat, setNewNoteTitleFormat] = useState( + 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() + + const handleCustomFormatInputChange: ChangeEventHandler = (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 ( +
+
New Note Defaults
+
+
Note Type
+
+ +
+
+
+
Title Format
+
+ +
+
+ {newNoteTitleFormat === NewNoteTitleFormat.CustomFormat && ( +
+
+ +
+
+ Preview: {dayjs().format(customNoteTitleFormat)} +
+ +
+ )} +
+ ) +} + +export default observer(NewNotePreferences) diff --git a/packages/web/src/javascripts/Components/ContentListView/Header/PreferenceMode.tsx b/packages/web/src/javascripts/Components/ContentListView/Header/PreferenceMode.tsx new file mode 100644 index 000000000..7fc0d4a3a --- /dev/null +++ b/packages/web/src/javascripts/Components/ContentListView/Header/PreferenceMode.tsx @@ -0,0 +1 @@ +export type PreferenceMode = 'global' | 'tag' diff --git a/packages/web/src/javascripts/Components/Dropdown/Dropdown.tsx b/packages/web/src/javascripts/Components/Dropdown/Dropdown.tsx index eca72c6e3..3ef076e9e 100644 --- a/packages/web/src/javascripts/Components/Dropdown/Dropdown.tsx +++ b/packages/web/src/javascripts/Components/Dropdown/Dropdown.tsx @@ -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 = ({ ) -const Dropdown: FunctionComponent = ({ id, label, items, value, onChange, disabled, className }) => { +const Dropdown: FunctionComponent = ({ + 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 = ({ id, label, items, value, o {label} { const current = items.find((item) => item.value === value) const icon = current ? current?.icon : null @@ -69,7 +82,7 @@ const Dropdown: FunctionComponent = ({ id, label, items, value, o }) }} /> - +
{items.map((item) => ( diff --git a/packages/web/src/javascripts/Components/Dropdown/StyledListboxButton.tsx b/packages/web/src/javascripts/Components/Dropdown/StyledListboxButton.tsx index 6f2734741..91ccef9e0 100644 --- a/packages/web/src/javascripts/Components/Dropdown/StyledListboxButton.tsx +++ b/packages/web/src/javascripts/Components/Dropdown/StyledListboxButton.tsx @@ -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; diff --git a/packages/web/src/javascripts/Components/Icon/IconPicker.tsx b/packages/web/src/javascripts/Components/Icon/IconPicker.tsx index 922f6bd4d..ce1e6a4e9 100644 --- a/packages/web/src/javascripts/Components/Icon/IconPicker.tsx +++ b/packages/web/src/javascripts/Components/Icon/IconPicker.tsx @@ -93,6 +93,7 @@ const IconPicker = ({ selectedValue, onIconChange, platform, className }: Props)
{currentType === 'icon' && ( , ) => { return type === MenuItemType.SwitchButton && typeof onChange === 'function' ? (
  • ) : (
  • diff --git a/packages/web/src/javascripts/Components/NoAccountWarning/NoAccountWarningContent.tsx b/packages/web/src/javascripts/Components/NoAccountWarning/NoAccountWarningContent.tsx index 4f4baf376..4e5619702 100644 --- a/packages/web/src/javascripts/Components/NoAccountWarning/NoAccountWarningContent.tsx +++ b/packages/web/src/javascripts/Components/NoAccountWarning/NoAccountWarningContent.tsx @@ -29,7 +29,12 @@ const NoAccountWarningContent = ({ accountMenuController, noAccountWarningContro

    Sign in or register to sync your notes to your other devices with end-to-end encryption.

    -