mirror of
https://github.com/standardnotes/app
synced 2026-09-13 18:46:08 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
189d70c1b6 | ||
|
|
d9db73ea05 | ||
|
|
ca7455a854 | ||
|
|
b7c502038f | ||
|
|
3b1892ed5a | ||
|
|
ee7f11c933 | ||
|
|
9248d0ff16 | ||
|
|
28ef9054fa |
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.18.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api
|
||||
|
||||
## [1.18.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api
|
||||
|
||||
# [1.18.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/api",
|
||||
"version": "1.18.0",
|
||||
"version": "1.18.2",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [3.23.250](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/desktop
|
||||
|
||||
## [3.23.249](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/desktop
|
||||
|
||||
## [3.23.248](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/desktop
|
||||
|
||||
## [3.23.247](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/desktop
|
||||
|
||||
## [3.23.246](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/desktop
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@standardnotes/desktop",
|
||||
"main": "./app/dist/index.js",
|
||||
"version": "3.23.246",
|
||||
"version": "3.23.250",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": "Standard Notes.",
|
||||
"private": true,
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.18.7](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/encryption
|
||||
|
||||
## [1.18.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/encryption
|
||||
|
||||
## [1.18.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-21)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/encryption
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/encryption",
|
||||
"version": "1.18.5",
|
||||
"version": "1.18.7",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.24.9](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/filepicker
|
||||
|
||||
## [1.24.8](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/filepicker
|
||||
|
||||
## [1.24.7](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-21)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/filepicker
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/filepicker",
|
||||
"version": "1.24.7",
|
||||
"version": "1.24.9",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.11.9](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/files
|
||||
|
||||
## [1.11.8](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/files
|
||||
|
||||
## [1.11.7](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-21)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/files
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/files",
|
||||
"version": "1.11.7",
|
||||
"version": "1.11.9",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,26 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [3.44.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* on mobile open links from editor in external browser ([#1860](https://github.com/standardnotes/app/issues/1860)) ([d9db73e](https://github.com/standardnotes/app/commit/d9db73ea056e2e6e2bde8e0c1de4a7090984ae9a))
|
||||
|
||||
## [3.44.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/mobile
|
||||
|
||||
## [3.44.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/mobile
|
||||
|
||||
# [3.44.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
### Features
|
||||
|
||||
* add preference on android to not confirm when exiting ([#1870](https://github.com/standardnotes/app/issues/1870)) ([28ef905](https://github.com/standardnotes/app/commit/28ef9054fad5236bc9f1fc87768d3c17059da4fb))
|
||||
|
||||
## [3.43.33](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/mobile
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
class WebProcessDeviceInterface {
|
||||
constructor(messageSender) {
|
||||
this.appVersion = '1.2.3'
|
||||
this.environment = 4
|
||||
this.environment = 3
|
||||
this.databases = []
|
||||
this.messageSender = messageSender
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/mobile",
|
||||
"version": "3.43.33",
|
||||
"version": "3.44.3",
|
||||
"author": "Standard Notes.",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RawKeychainValue,
|
||||
removeFromArray,
|
||||
TransferPayload,
|
||||
UuidString,
|
||||
} from '@standardnotes/snjs'
|
||||
import { Alert, Linking, PermissionsAndroid, Platform, StatusBar } from 'react-native'
|
||||
import FileViewer from 'react-native-file-viewer'
|
||||
@@ -79,6 +80,7 @@ export class MobileDevice implements MobileDeviceInterface {
|
||||
private eventObservers: MobileDeviceEventHandler[] = []
|
||||
public isDarkMode = false
|
||||
public statusBarBgColor: string | undefined
|
||||
private componentUrls: Map<UuidString, string> = new Map()
|
||||
|
||||
constructor(
|
||||
private stateObserverService?: AppStateObserverService,
|
||||
@@ -567,7 +569,12 @@ export class MobileDevice implements MobileDeviceInterface {
|
||||
return true
|
||||
}
|
||||
|
||||
confirmAndExit() {
|
||||
exitApp(shouldConfirm?: boolean) {
|
||||
if (!shouldConfirm) {
|
||||
SNReactNative.exitApp()
|
||||
return
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Close app',
|
||||
'Do you want to close the app?',
|
||||
@@ -591,4 +598,16 @@ export class MobileDevice implements MobileDeviceInterface {
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
addComponentUrl(componentUuid: UuidString, componentUrl: string) {
|
||||
this.componentUrls.set(componentUuid, componentUrl)
|
||||
}
|
||||
|
||||
removeComponentUrl(componentUuid: UuidString) {
|
||||
this.componentUrls.delete(componentUuid)
|
||||
}
|
||||
|
||||
isUrlComponentUrl(url: string): boolean {
|
||||
return Array.from(this.componentUrls.values()).includes(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Keyboard, Platform } from 'react-native'
|
||||
import VersionInfo from 'react-native-version-info'
|
||||
import { WebView, WebViewMessageEvent } from 'react-native-webview'
|
||||
import { OnShouldStartLoadWithRequest } from 'react-native-webview/lib/WebViewTypes'
|
||||
import pjson from '../package.json'
|
||||
import { AndroidBackHandlerService } from './AndroidBackHandlerService'
|
||||
import { AppStateObserverService } from './AppStateObserverService'
|
||||
@@ -23,6 +24,7 @@ export const MobileWebAppContainer = () => {
|
||||
|
||||
const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => void }) => {
|
||||
const webViewRef = useRef<WebView>(null)
|
||||
|
||||
const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/src/index.html'
|
||||
const stateService = useMemo(() => new AppStateObserverService(), [])
|
||||
const androidBackHandlerService = useMemo(() => new AndroidBackHandlerService(), [])
|
||||
@@ -99,7 +101,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
|
||||
class WebProcessDeviceInterface {
|
||||
constructor(messageSender) {
|
||||
this.appVersion = '${pjson.version} (${VersionInfo.buildVersion})'
|
||||
this.environment = 4
|
||||
this.environment = 3
|
||||
this.platform = ${device.platform}
|
||||
this.databases = []
|
||||
this.messageSender = messageSender
|
||||
@@ -195,6 +197,34 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
|
||||
webViewRef.current?.postMessage(JSON.stringify({ messageId, returnValue, messageType: 'reply' }))
|
||||
}
|
||||
|
||||
const onShouldStartLoadWithRequest: OnShouldStartLoadWithRequest = (request) => {
|
||||
/**
|
||||
* We want to handle link clicks within an editor by opening the browser
|
||||
* instead of loading inline. On iOS, onShouldStartLoadWithRequest is
|
||||
* called for all requests including the initial request to load the editor.
|
||||
* On iOS, clicks in the editors have a navigationType of 'click', but on
|
||||
* Android, this is not the case (no navigationType).
|
||||
* However, on Android, this function is not called for the initial request.
|
||||
* So that might be one way to determine if this request is a click or the
|
||||
* actual editor load request. But I don't think it's safe to rely on this
|
||||
* being the case in the future. So on Android, we'll handle url loads only
|
||||
* if the url isn't equal to the editor url.
|
||||
*/
|
||||
|
||||
const shouldStopRequest =
|
||||
(Platform.OS === 'ios' && request.navigationType === 'click') ||
|
||||
(Platform.OS === 'android' && request.url !== sourceUri)
|
||||
|
||||
const isComponentUrl = device.isUrlComponentUrl(request.url)
|
||||
|
||||
if (shouldStopRequest && !isComponentUrl) {
|
||||
device.openUrl(request.url)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
@@ -210,6 +240,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
|
||||
onRenderProcessGone={() => {
|
||||
webViewRef.current?.reload()
|
||||
}}
|
||||
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
||||
allowFileAccess={true}
|
||||
allowUniversalAccessFromFileURLs={true}
|
||||
injectedJavaScriptBeforeContentLoaded={injectedJS}
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.31.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* on mobile open links from editor in external browser ([#1860](https://github.com/standardnotes/app/issues/1860)) ([d9db73e](https://github.com/standardnotes/app/commit/d9db73ea056e2e6e2bde8e0c1de4a7090984ae9a))
|
||||
|
||||
# [1.31.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
### Features
|
||||
|
||||
* per-tag display preferences ([#1868](https://github.com/standardnotes/app/issues/1868)) ([ee7f11c](https://github.com/standardnotes/app/commit/ee7f11c933285234b20fa2d5db4fcf0d6a5dce9d))
|
||||
|
||||
# [1.30.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-21)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/models",
|
||||
"version": "1.30.0",
|
||||
"version": "1.31.1",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,4 @@ export enum Environment {
|
||||
Web = 1,
|
||||
Desktop = 2,
|
||||
Mobile = 3,
|
||||
NativeMobileWeb = 4,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './Tag'
|
||||
export * from './TagMutator'
|
||||
export * from './TagContent'
|
||||
export * from './TagPreferences'
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.3.179](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/releases
|
||||
|
||||
## [1.3.178](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/releases
|
||||
|
||||
## [1.3.177](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/releases
|
||||
|
||||
## [1.3.176](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/releases
|
||||
|
||||
## [1.3.175](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/releases
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/releases",
|
||||
"version": "1.3.175",
|
||||
"version": "1.3.179",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"main": "dist/releases.json",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -3,6 +3,24 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.37.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* on mobile open links from editor in external browser ([#1860](https://github.com/standardnotes/app/issues/1860)) ([d9db73e](https://github.com/standardnotes/app/commit/d9db73ea056e2e6e2bde8e0c1de4a7090984ae9a))
|
||||
|
||||
# [1.37.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
### Features
|
||||
|
||||
* per-tag display preferences ([#1868](https://github.com/standardnotes/app/issues/1868)) ([ee7f11c](https://github.com/standardnotes/app/commit/ee7f11c933285234b20fa2d5db4fcf0d6a5dce9d))
|
||||
|
||||
# [1.36.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
### Features
|
||||
|
||||
* add preference on android to not confirm when exiting ([#1870](https://github.com/standardnotes/app/issues/1870)) ([28ef905](https://github.com/standardnotes/app/commit/28ef9054fad5236bc9f1fc87768d3c17059da4fb))
|
||||
|
||||
# [1.35.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/services",
|
||||
"version": "1.35.0",
|
||||
"version": "1.37.1",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -16,5 +16,8 @@ export interface MobileDeviceInterface extends DeviceInterface {
|
||||
shareBase64AsFile(base64: string, filename: string): Promise<void>
|
||||
downloadBase64AsFile(base64: string, filename: string, saveInTempLocation?: boolean): Promise<string | undefined>
|
||||
previewFile(base64: string, filename: string): Promise<boolean>
|
||||
confirmAndExit(): void
|
||||
exitApp(confirm?: boolean): void
|
||||
addComponentUrl(componentUuid: string, componentUrl: string): void
|
||||
removeComponentUrl(componentUuid: string): void
|
||||
isUrlComponentUrl(url: string): boolean
|
||||
}
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [2.144.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* on mobile open links from editor in external browser ([#1860](https://github.com/standardnotes/app/issues/1860)) ([d9db73e](https://github.com/standardnotes/app/commit/d9db73ea056e2e6e2bde8e0c1de4a7090984ae9a))
|
||||
|
||||
# [2.144.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
### Features
|
||||
|
||||
* per-tag display preferences ([#1868](https://github.com/standardnotes/app/issues/1868)) ([ee7f11c](https://github.com/standardnotes/app/commit/ee7f11c933285234b20fa2d5db4fcf0d6a5dce9d))
|
||||
|
||||
## [2.143.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/snjs
|
||||
|
||||
## [2.143.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* @jest-environment jsdom
|
||||
*/
|
||||
|
||||
import { SNLog } from './../Log'
|
||||
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
|
||||
import { AlertService, DeviceInterface, namespacedKey, RawStorageKey } from '@standardnotes/services'
|
||||
|
||||
@@ -940,7 +940,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
}
|
||||
|
||||
isNativeMobileWeb() {
|
||||
return this.environment === Environment.NativeMobileWeb
|
||||
return this.environment === Environment.Mobile
|
||||
}
|
||||
|
||||
getDeinitMode(): DeinitMode {
|
||||
@@ -1353,10 +1353,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
}
|
||||
|
||||
private createComponentManager() {
|
||||
const MaybeSwappedComponentManager = this.getClass<typeof InternalServices.SNComponentManager>(
|
||||
InternalServices.SNComponentManager,
|
||||
)
|
||||
this.componentManagerService = new MaybeSwappedComponentManager(
|
||||
this.componentManagerService = new InternalServices.SNComponentManager(
|
||||
this.itemManager,
|
||||
this.syncService,
|
||||
this.featuresService,
|
||||
@@ -1365,6 +1362,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
this.environment,
|
||||
this.platform,
|
||||
this.internalEventBus,
|
||||
this.deviceInterface,
|
||||
)
|
||||
this.services.push(this.componentManagerService)
|
||||
}
|
||||
@@ -1647,13 +1645,4 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
this.statusService = new ExternalServices.StatusService(this.internalEventBus)
|
||||
this.services.push(this.statusService)
|
||||
}
|
||||
|
||||
private getClass<T>(base: T) {
|
||||
const swapClass = this.options.swapClasses?.find((candidate) => candidate.swap === base)
|
||||
if (swapClass) {
|
||||
return swapClass.with as T
|
||||
} else {
|
||||
return base
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,14 +10,6 @@ export interface ApplicationDisplayOptions {
|
||||
}
|
||||
|
||||
export interface ApplicationOptionalConfiguratioOptions {
|
||||
/**
|
||||
* Gives consumers the ability to provide their own custom
|
||||
* subclass for a service. swapClasses should be an array of key/value pairs
|
||||
* consisting of keys 'swap' and 'with'. 'swap' is the base class you wish to replace,
|
||||
* and 'with' is the custom subclass to use.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
swapClasses?: { swap: any; with: any }[]
|
||||
/**
|
||||
* URL for WebSocket providing permissions and roles information.
|
||||
*/
|
||||
|
||||
@@ -28,22 +28,11 @@ export function platformToString(platform: Platform) {
|
||||
return map[platform]
|
||||
}
|
||||
|
||||
export function environmentFromString(string: string) {
|
||||
const map: Record<string, Environment> = {
|
||||
web: Environment.Web,
|
||||
desktop: Environment.Desktop,
|
||||
mobile: Environment.Mobile,
|
||||
nativeMobileWeb: Environment.NativeMobileWeb,
|
||||
}
|
||||
return map[string]
|
||||
}
|
||||
|
||||
export function environmentToString(environment: Environment) {
|
||||
const map = {
|
||||
[Environment.Web]: 'web',
|
||||
[Environment.Desktop]: 'desktop',
|
||||
[Environment.Mobile]: 'mobile',
|
||||
[Environment.NativeMobileWeb]: 'native-mobile-web',
|
||||
[Environment.Mobile]: 'native-mobile-web',
|
||||
}
|
||||
return map[environment]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,12 @@ import {
|
||||
} from '@standardnotes/features'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { GenericItem, SNComponent, Environment, Platform } from '@standardnotes/models'
|
||||
import { DesktopManagerInterface, InternalEventBusInterface, AlertService } from '@standardnotes/services'
|
||||
import {
|
||||
DesktopManagerInterface,
|
||||
InternalEventBusInterface,
|
||||
AlertService,
|
||||
DeviceInterface,
|
||||
} from '@standardnotes/services'
|
||||
import { ItemManager } from '@Lib/Services/Items/ItemManager'
|
||||
import { SNFeaturesService } from '@Lib/Services/Features/FeaturesService'
|
||||
import { SNComponentManager } from './ComponentManager'
|
||||
@@ -27,6 +32,7 @@ describe('featuresService', () => {
|
||||
let syncService: SNSyncService
|
||||
let prefsService: SNPreferencesService
|
||||
let internalEventBus: InternalEventBusInterface
|
||||
let device: DeviceInterface
|
||||
|
||||
const desktopExtHost = 'http://localhost:123'
|
||||
|
||||
@@ -53,6 +59,7 @@ describe('featuresService', () => {
|
||||
environment,
|
||||
platform,
|
||||
internalEventBus,
|
||||
device,
|
||||
)
|
||||
manager.setDesktopManager(desktopManager)
|
||||
return manager
|
||||
@@ -81,6 +88,8 @@ describe('featuresService', () => {
|
||||
|
||||
internalEventBus = {} as jest.Mocked<InternalEventBusInterface>
|
||||
internalEventBus.publish = jest.fn()
|
||||
|
||||
device = {} as jest.Mocked<DeviceInterface>
|
||||
})
|
||||
|
||||
const nativeComponent = (identifier?: FeatureIdentifier, file_type?: FeatureDescription['file_type']) => {
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
DesktopManagerInterface,
|
||||
InternalEventBusInterface,
|
||||
AlertService,
|
||||
DeviceInterface,
|
||||
isMobileDevice,
|
||||
} from '@standardnotes/services'
|
||||
|
||||
const DESKTOP_URL_PREFIX = 'sn://'
|
||||
@@ -82,23 +84,21 @@ export class SNComponentManager
|
||||
private environment: Environment,
|
||||
private platform: Platform,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
private device: DeviceInterface,
|
||||
) {
|
||||
super(internalEventBus)
|
||||
this.loggingEnabled = false
|
||||
|
||||
this.addItemObserver()
|
||||
|
||||
/* On mobile, events listeners are handled by a respective component */
|
||||
if (environment !== Environment.Mobile) {
|
||||
window.addEventListener
|
||||
? window.addEventListener('focus', this.detectFocusChange, true)
|
||||
: window.attachEvent('onfocusout', this.detectFocusChange)
|
||||
window.addEventListener
|
||||
? window.addEventListener('blur', this.detectFocusChange, true)
|
||||
: window.attachEvent('onblur', this.detectFocusChange)
|
||||
window.addEventListener
|
||||
? window.addEventListener('focus', this.detectFocusChange, true)
|
||||
: window.attachEvent('onfocusout', this.detectFocusChange)
|
||||
window.addEventListener
|
||||
? window.addEventListener('blur', this.detectFocusChange, true)
|
||||
: window.attachEvent('onblur', this.detectFocusChange)
|
||||
|
||||
window.addEventListener('message', this.onWindowMessage, true)
|
||||
}
|
||||
window.addEventListener('message', this.onWindowMessage, true)
|
||||
}
|
||||
|
||||
get isDesktop(): boolean {
|
||||
@@ -143,7 +143,7 @@ export class SNComponentManager
|
||||
this.removeItemObserver?.()
|
||||
;(this.removeItemObserver as unknown) = undefined
|
||||
|
||||
if (window && !this.isMobile) {
|
||||
if (window) {
|
||||
window.removeEventListener('focus', this.detectFocusChange, true)
|
||||
window.removeEventListener('blur', this.detectFocusChange, true)
|
||||
window.removeEventListener('message', this.onWindowMessage, true)
|
||||
@@ -221,9 +221,23 @@ export class SNComponentManager
|
||||
addItemObserver(): void {
|
||||
this.removeItemObserver = this.itemManager.addObserver<SNComponent>(
|
||||
[ContentType.Component, ContentType.Theme],
|
||||
({ changed, inserted, source }) => {
|
||||
({ changed, inserted, removed, source }) => {
|
||||
const items = [...changed, ...inserted]
|
||||
this.handleChangedComponents(items, source)
|
||||
|
||||
const device = this.device
|
||||
if (isMobileDevice(device) && 'addComponentUrl' in device) {
|
||||
inserted.forEach((component) => {
|
||||
const url = this.urlForComponent(component)
|
||||
if (url) {
|
||||
device.addComponentUrl(component.uuid, url)
|
||||
}
|
||||
})
|
||||
|
||||
removed.forEach((component) => {
|
||||
device.removeComponentUrl(component.uuid)
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -271,9 +285,6 @@ export class SNComponentManager
|
||||
}
|
||||
|
||||
getActiveThemes(): SNTheme[] {
|
||||
if (this.environment === Environment.Mobile) {
|
||||
throw Error('getActiveThemes must be handled separately by mobile')
|
||||
}
|
||||
return this.componentsForArea(ComponentArea.Themes).filter((theme) => {
|
||||
return theme.active
|
||||
}) as SNTheme[]
|
||||
@@ -301,14 +312,10 @@ export class SNComponentManager
|
||||
}
|
||||
}
|
||||
|
||||
const isWeb = this.environment === Environment.Web
|
||||
const isMobileWebView = this.environment === Environment.NativeMobileWeb
|
||||
const isMobile = this.environment === Environment.Mobile
|
||||
if (nativeFeature) {
|
||||
if (!isWeb && !isMobileWebView) {
|
||||
throw Error('Mobile must override urlForComponent to handle native paths')
|
||||
}
|
||||
let baseUrlRequiredForThemesInsideEditors = window.location.origin
|
||||
if (isMobileWebView) {
|
||||
if (isMobile) {
|
||||
baseUrlRequiredForThemesInsideEditors = window.location.href.split('/index.html')[0]
|
||||
}
|
||||
return `${baseUrlRequiredForThemesInsideEditors}/components/assets/${component.identifier}/${nativeFeature.index_path}`
|
||||
|
||||
@@ -92,7 +92,7 @@ export class DiskStorageService extends Services.AbstractService implements Serv
|
||||
public setEncryptionPolicy(encryptionPolicy: Services.StorageEncryptionPolicy, persist = true): void {
|
||||
if (
|
||||
encryptionPolicy === Services.StorageEncryptionPolicy.Disabled &&
|
||||
![Environment.Mobile, Environment.NativeMobileWeb].includes(this.environment)
|
||||
![Environment.Mobile].includes(this.environment)
|
||||
) {
|
||||
throw Error('Disabling storage encryption is only available on mobile.')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/snjs",
|
||||
"version": "2.143.2",
|
||||
"version": "2.144.1",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,18 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.10.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/ui-services
|
||||
|
||||
## [1.10.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/ui-services
|
||||
|
||||
## [1.10.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/ui-services
|
||||
|
||||
# [1.10.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/ui-services",
|
||||
"version": "1.10.0",
|
||||
"version": "1.10.3",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [3.87.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* on mobile open links from editor in external browser ([#1860](https://github.com/standardnotes/app/issues/1860)) ([d9db73e](https://github.com/standardnotes/app/commit/d9db73ea056e2e6e2bde8e0c1de4a7090984ae9a))
|
||||
|
||||
## [3.87.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* item linking search results ([#1869](https://github.com/standardnotes/app/issues/1869)) ([b7c5020](https://github.com/standardnotes/app/commit/b7c502038fe0afc05b983ff3f58af0d6f9a10540))
|
||||
|
||||
# [3.87.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-25)
|
||||
|
||||
### Features
|
||||
|
||||
* per-tag display preferences ([#1868](https://github.com/standardnotes/app/issues/1868)) ([ee7f11c](https://github.com/standardnotes/app/commit/ee7f11c933285234b20fa2d5db4fcf0d6a5dce9d))
|
||||
|
||||
# [3.86.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
### Features
|
||||
|
||||
* add preference on android to not confirm when exiting ([#1870](https://github.com/standardnotes/app/issues/1870)) ([28ef905](https://github.com/standardnotes/app/commit/28ef9054fad5236bc9f1fc87768d3c17059da4fb))
|
||||
|
||||
## [3.85.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -22,4 +22,5 @@ module.exports = {
|
||||
'^.+\\.(ts|tsx)?$': 'ts-jest',
|
||||
'\\.svg$': 'svg-jest',
|
||||
},
|
||||
testEnvironment: 'jsdom',
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/web",
|
||||
"version": "3.85.2",
|
||||
"version": "3.87.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"main": "dist/app.js",
|
||||
"author": "Standard Notes.",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
+5
-5
@@ -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>
|
||||
|
||||
+211
-79
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+3
-4
@@ -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
|
||||
|
||||
+278
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
export type PreferenceMode = 'global' | 'tag'
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
@@ -1,75 +1,28 @@
|
||||
import Dropdown from '@/Components/Dropdown/Dropdown'
|
||||
import { DropdownItem } from '@/Components/Dropdown/DropdownItem'
|
||||
import {
|
||||
FeatureIdentifier,
|
||||
PrefKey,
|
||||
ComponentArea,
|
||||
ComponentMutator,
|
||||
SNComponent,
|
||||
NewNoteTitleFormat,
|
||||
} 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 }) => {
|
||||
const [editorItems, setEditorItems] = useState<DropdownItem[]>([])
|
||||
const [defaultEditorValue, setDefaultEditorValue] = useState(
|
||||
() => getDefaultEditor(application)?.package_info?.identifier || 'plain-editor',
|
||||
const [androidConfirmBeforeExit, setAndroidConfirmBeforeExit] = useState(
|
||||
() => (application.getValue(AndroidConfirmBeforeExitKey) as boolean) ?? true,
|
||||
)
|
||||
|
||||
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]),
|
||||
)
|
||||
@@ -79,103 +32,28 @@ 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 toggleAndroidConfirmBeforeExit = () => {
|
||||
const newValue = !androidConfirmBeforeExit
|
||||
setAndroidConfirmBeforeExit(newValue)
|
||||
application.setValue(AndroidConfirmBeforeExitKey, newValue)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>Defaults</Title>
|
||||
<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" />
|
||||
{application.platform === Platform.Android && (
|
||||
<>
|
||||
<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>
|
||||
<HorizontalSeparator classes="my-4" />
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Spellcheck</Subtitle>
|
||||
|
||||
-80
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import {
|
||||
AnonymousReference,
|
||||
ContentReferenceType,
|
||||
ContentType,
|
||||
FileItem,
|
||||
FileToNoteReference,
|
||||
InternalEventBus,
|
||||
SNNote,
|
||||
} from '@standardnotes/snjs'
|
||||
import { FilesController } from './FilesController'
|
||||
import { ItemListController } from './ItemList/ItemListController'
|
||||
import { LinkingController } from './LinkingController'
|
||||
import { NavigationController } from './Navigation/NavigationController'
|
||||
import { SelectedItemsController } from './SelectedItemsController'
|
||||
import { SubscriptionController } from './Subscription/SubscriptionController'
|
||||
|
||||
const createNote = (name: string, options?: Partial<SNNote>) => {
|
||||
return {
|
||||
title: name,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
uuid: String(Math.random()),
|
||||
content_type: ContentType.Note,
|
||||
...options,
|
||||
} as jest.Mocked<SNNote>
|
||||
}
|
||||
|
||||
const createFile = (name: string, options?: Partial<FileItem>) => {
|
||||
return {
|
||||
title: name,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
uuid: String(Math.random()),
|
||||
content_type: ContentType.File,
|
||||
...options,
|
||||
} as jest.Mocked<FileItem>
|
||||
}
|
||||
|
||||
describe('LinkingController', () => {
|
||||
let linkingController: LinkingController
|
||||
let application: WebApplication
|
||||
let navigationController: NavigationController
|
||||
let selectionController: SelectedItemsController
|
||||
let eventBus: InternalEventBus
|
||||
|
||||
let itemListController: ItemListController
|
||||
let filesController: FilesController
|
||||
let subscriptionController: SubscriptionController
|
||||
|
||||
beforeEach(() => {
|
||||
application = {} as jest.Mocked<WebApplication>
|
||||
application.getPreference = jest.fn()
|
||||
application.addSingleEventObserver = jest.fn()
|
||||
application.streamItems = jest.fn()
|
||||
|
||||
navigationController = {} as jest.Mocked<NavigationController>
|
||||
|
||||
selectionController = {} as jest.Mocked<SelectedItemsController>
|
||||
|
||||
eventBus = {} as jest.Mocked<InternalEventBus>
|
||||
|
||||
itemListController = {} as jest.Mocked<ItemListController>
|
||||
filesController = {} as jest.Mocked<FilesController>
|
||||
subscriptionController = {} as jest.Mocked<SubscriptionController>
|
||||
|
||||
linkingController = new LinkingController(application, navigationController, selectionController, eventBus)
|
||||
linkingController.setServicesPostConstruction(itemListController, filesController, subscriptionController)
|
||||
})
|
||||
|
||||
describe('isValidSearchResult', () => {
|
||||
it("should not be valid result if it doesn't match query", () => {
|
||||
const searchQuery = 'test'
|
||||
|
||||
const file = createFile('anotherFile')
|
||||
|
||||
const isFileValidResult = linkingController.isValidSearchResult(file, searchQuery)
|
||||
|
||||
expect(isFileValidResult).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not be valid result if item is archived or trashed', () => {
|
||||
const searchQuery = 'test'
|
||||
|
||||
const archived = createFile('test', { archived: true })
|
||||
|
||||
const trashed = createFile('test', { trashed: true })
|
||||
|
||||
const isArchivedFileValidResult = linkingController.isValidSearchResult(archived, searchQuery)
|
||||
expect(isArchivedFileValidResult).toBeFalsy()
|
||||
|
||||
const isTrashedFileValidResult = linkingController.isValidSearchResult(trashed, searchQuery)
|
||||
expect(isTrashedFileValidResult).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should not be valid result if result is active item', () => {
|
||||
const searchQuery = 'test'
|
||||
|
||||
const activeItem = createFile('test', { uuid: 'same-uuid' })
|
||||
|
||||
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeItem })
|
||||
|
||||
const result = createFile('test', { uuid: 'same-uuid' })
|
||||
|
||||
const isFileValidResult = linkingController.isValidSearchResult(result, searchQuery)
|
||||
expect(isFileValidResult).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should be valid result if it matches query even case insensitive', () => {
|
||||
const searchQuery = 'test'
|
||||
|
||||
const file = createFile('TeSt')
|
||||
|
||||
const isFileValidResult = linkingController.isValidSearchResult(file, searchQuery)
|
||||
|
||||
expect(isFileValidResult).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSearchResultAlreadyLinked', () => {
|
||||
it('should be true if active item & result are same content type & active item references result', () => {
|
||||
const activeItem = createFile('test', {
|
||||
uuid: 'active-item',
|
||||
references: [
|
||||
{
|
||||
reference_type: ContentReferenceType.FileToFile,
|
||||
uuid: 'result',
|
||||
} as AnonymousReference,
|
||||
],
|
||||
})
|
||||
const result = createFile('test', { uuid: 'result', references: [] })
|
||||
|
||||
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeItem })
|
||||
|
||||
const isFileAlreadyLinked = linkingController.isSearchResultAlreadyLinked(result)
|
||||
expect(isFileAlreadyLinked).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should be false if active item & result are same content type & result references active item', () => {
|
||||
const activeItem = createFile('test', {
|
||||
uuid: 'active-item',
|
||||
references: [],
|
||||
})
|
||||
const result = createFile('test', {
|
||||
uuid: 'result',
|
||||
references: [
|
||||
{
|
||||
reference_type: ContentReferenceType.FileToFile,
|
||||
uuid: 'active-item',
|
||||
} as AnonymousReference,
|
||||
],
|
||||
})
|
||||
|
||||
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeItem })
|
||||
|
||||
const isFileAlreadyLinked = linkingController.isSearchResultAlreadyLinked(result)
|
||||
expect(isFileAlreadyLinked).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should be true if active item & result are different content type & result references active item', () => {
|
||||
const activeNote = createNote('test', {
|
||||
uuid: 'active-note',
|
||||
references: [],
|
||||
})
|
||||
|
||||
const fileResult = createFile('test', {
|
||||
uuid: 'file-result',
|
||||
references: [
|
||||
{
|
||||
reference_type: ContentReferenceType.FileToNote,
|
||||
uuid: 'active-note',
|
||||
} as FileToNoteReference,
|
||||
],
|
||||
})
|
||||
|
||||
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeNote })
|
||||
|
||||
const isFileResultAlreadyLinked = linkingController.isSearchResultAlreadyLinked(fileResult)
|
||||
expect(isFileResultAlreadyLinked).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should be true if active item & result are different content type & active item references result', () => {
|
||||
const activeFile = createNote('test', {
|
||||
uuid: 'active-file',
|
||||
references: [
|
||||
{
|
||||
reference_type: ContentReferenceType.FileToNote,
|
||||
uuid: 'note-result',
|
||||
} as FileToNoteReference,
|
||||
],
|
||||
})
|
||||
|
||||
const noteResult = createFile('test', {
|
||||
uuid: 'note-result',
|
||||
references: [],
|
||||
})
|
||||
|
||||
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeFile })
|
||||
|
||||
const isNoteResultAlreadyLinked = linkingController.isSearchResultAlreadyLinked(noteResult)
|
||||
expect(isNoteResultAlreadyLinked).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should be false if active item & result are different content type & neither references the other', () => {
|
||||
const activeFile = createNote('test', {
|
||||
uuid: 'active-file',
|
||||
references: [],
|
||||
})
|
||||
|
||||
const noteResult = createFile('test', {
|
||||
uuid: 'note-result',
|
||||
references: [],
|
||||
})
|
||||
|
||||
Object.defineProperty(itemListController, 'activeControllerItem', { value: activeFile })
|
||||
|
||||
const isNoteResultAlreadyLinked = linkingController.isSearchResultAlreadyLinked(noteResult)
|
||||
expect(isNoteResultAlreadyLinked).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -342,72 +342,104 @@ export class LinkingController extends AbstractViewController {
|
||||
this.application.sync.sync().catch(console.error)
|
||||
}
|
||||
|
||||
getSearchResults = (searchQuery: string) => {
|
||||
if (!searchQuery.length) {
|
||||
return {
|
||||
linkedResults: [],
|
||||
unlinkedResults: [],
|
||||
shouldShowCreateTag: false,
|
||||
}
|
||||
isValidSearchResult = (item: LinkableItem, searchQuery: string) => {
|
||||
const title = item instanceof SNTag ? this.application.items.getTagLongTitle(item) : item.title ?? ''
|
||||
|
||||
const matchesQuery = title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
|
||||
const isActiveItem = this.activeItem?.uuid === item.uuid
|
||||
const isArchivedOrTrashed = item.archived || item.trashed
|
||||
|
||||
const isValidSearchResult = matchesQuery && !isActiveItem && !isArchivedOrTrashed
|
||||
|
||||
return isValidSearchResult
|
||||
}
|
||||
|
||||
isSearchResultAlreadyLinked = (item: LinkableItem) => {
|
||||
if (!this.activeItem) {
|
||||
return false
|
||||
}
|
||||
|
||||
const searchResults = naturalSort(
|
||||
this.application.items.getItems([ContentType.Note, ContentType.File, ContentType.Tag]).filter((item) => {
|
||||
const title = item instanceof SNTag ? this.application.items.getTagLongTitle(item) : item.title
|
||||
const matchesQuery = title?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
const isNotActiveItem = this.activeItem?.uuid !== item.uuid
|
||||
const isArchivedOrTrashed = item.archived || item.trashed
|
||||
return matchesQuery && isNotActiveItem && !isArchivedOrTrashed
|
||||
}),
|
||||
let isAlreadyLinked = false
|
||||
|
||||
const isItemReferencedByActiveItem = this.activeItem.references.some((ref) => ref.uuid === item.uuid)
|
||||
const isActiveItemReferencedByItem = item.references.some((ref) => ref.uuid === this.activeItem?.uuid)
|
||||
|
||||
if (this.activeItem.content_type === item.content_type) {
|
||||
isAlreadyLinked = isItemReferencedByActiveItem
|
||||
} else {
|
||||
isAlreadyLinked = isActiveItemReferencedByItem || isItemReferencedByActiveItem
|
||||
}
|
||||
|
||||
return isAlreadyLinked
|
||||
}
|
||||
|
||||
isSearchResultExistingTag = (result: DecryptedItemInterface<ItemContent>, searchQuery: string) =>
|
||||
result.content_type === ContentType.Tag && result.title === searchQuery
|
||||
|
||||
getSearchResults = (searchQuery: string) => {
|
||||
let unlinkedResults: LinkableItem[] = []
|
||||
const linkedResults: ItemLink<LinkableItem>[] = []
|
||||
let shouldShowCreateTag = false
|
||||
|
||||
const defaultReturnValue = {
|
||||
linkedResults,
|
||||
unlinkedResults,
|
||||
shouldShowCreateTag,
|
||||
}
|
||||
|
||||
if (!searchQuery.length) {
|
||||
return defaultReturnValue
|
||||
}
|
||||
|
||||
if (!this.activeItem) {
|
||||
return defaultReturnValue
|
||||
}
|
||||
|
||||
const searchableItems = naturalSort(
|
||||
this.application.items.getItems([ContentType.Note, ContentType.File, ContentType.Tag]),
|
||||
'title',
|
||||
)
|
||||
|
||||
const isAlreadyLinked = (item: DecryptedItemInterface<ItemContent>) => {
|
||||
if (!this.activeItem) {
|
||||
return false
|
||||
const unlinkedTags: LinkableItem[] = []
|
||||
const unlinkedNotes: LinkableItem[] = []
|
||||
const unlinkedFiles: LinkableItem[] = []
|
||||
|
||||
for (let index = 0; index < searchableItems.length; index++) {
|
||||
const item = searchableItems[index]
|
||||
|
||||
if (!this.isValidSearchResult(item, searchQuery)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const isItemReferencedByActiveItem = this.application.items
|
||||
.itemsReferencingItem(item)
|
||||
.some((linkedItem) => linkedItem.uuid === this.activeItem?.uuid)
|
||||
const isActiveItemReferencedByItem = this.application.items
|
||||
.itemsReferencingItem(this.activeItem)
|
||||
.some((linkedItem) => linkedItem.uuid === item.uuid)
|
||||
|
||||
if (this.activeItem.content_type === item.content_type) {
|
||||
return isItemReferencedByActiveItem
|
||||
if (this.isSearchResultAlreadyLinked(item)) {
|
||||
if (linkedResults.length < 20) {
|
||||
linkedResults.push(this.createLinkFromItem(item, 'linked'))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
return isActiveItemReferencedByItem || isItemReferencedByActiveItem
|
||||
if (unlinkedTags.length < 5 && item.content_type === ContentType.Tag) {
|
||||
unlinkedTags.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
if (unlinkedNotes.length < 5 && item.content_type === ContentType.Note) {
|
||||
unlinkedNotes.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
if (unlinkedFiles.length < 5 && item.content_type === ContentType.File) {
|
||||
unlinkedFiles.push(item)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const prioritizeTagResult = (
|
||||
itemA: DecryptedItemInterface<ItemContent>,
|
||||
itemB: DecryptedItemInterface<ItemContent>,
|
||||
) => {
|
||||
if (itemA.content_type === ContentType.Tag && itemB.content_type !== ContentType.Tag) {
|
||||
return -1
|
||||
}
|
||||
if (itemB.content_type === ContentType.Tag && itemA.content_type !== ContentType.Tag) {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
unlinkedResults = unlinkedTags.concat(unlinkedNotes).concat(unlinkedFiles)
|
||||
|
||||
const unlinkedResults = searchResults
|
||||
.slice(0, 20)
|
||||
.filter((item) => !isAlreadyLinked(item))
|
||||
.sort(prioritizeTagResult)
|
||||
const linkedResults = searchResults
|
||||
.filter(isAlreadyLinked)
|
||||
.slice(0, 20)
|
||||
.map((item) => this.createLinkFromItem(item, 'linked'))
|
||||
|
||||
const isResultExistingTag = (result: DecryptedItemInterface<ItemContent>) =>
|
||||
result.content_type === ContentType.Tag && result.title === searchQuery
|
||||
|
||||
const shouldShowCreateTag =
|
||||
!linkedResults.find((link) => isResultExistingTag(link.item)) && !unlinkedResults.find(isResultExistingTag)
|
||||
shouldShowCreateTag =
|
||||
!linkedResults.find((link) => this.isSearchResultExistingTag(link.item, searchQuery)) &&
|
||||
!unlinkedResults.find((item) => this.isSearchResultExistingTag(item, searchQuery))
|
||||
|
||||
return {
|
||||
unlinkedResults,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export class MobileWebReceiver {
|
||||
this.handleNativeEvent(nativeEvent)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error parsing message from React Native', error)
|
||||
console.log('[MobileWebReceiver] Error parsing message from React Native', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { AndroidConfirmBeforeExitKey } from '@/Components/Preferences/Panes/General/Defaults'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { createContext, memo, ReactNode, useCallback, useContext, useEffect } from 'react'
|
||||
|
||||
@@ -34,7 +35,10 @@ const AndroidBackHandlerProvider = ({ application, children }: ProviderProps) =>
|
||||
|
||||
useEffect(() => {
|
||||
const removeListener = addAndroidBackHandler(() => {
|
||||
application.mobileDevice().confirmAndExit()
|
||||
const shouldConfirm = (application.getValue(AndroidConfirmBeforeExitKey) as boolean) ?? true
|
||||
|
||||
application.mobileDevice().exitApp(shouldConfirm)
|
||||
|
||||
return true
|
||||
})
|
||||
return () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ export async function openSubscriptionDashboard(application: SNApplication) {
|
||||
|
||||
const url = `${window.dashboardUrl}?subscription_token=${token}`
|
||||
|
||||
if (application.deviceInterface.environment === Environment.NativeMobileWeb) {
|
||||
if (application.deviceInterface.environment === Environment.Mobile) {
|
||||
application.deviceInterface.openUrl(url)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user