Compare commits

...
Author SHA1 Message Date
StandardNotes CI d65abb9a49 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].4
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2022-11-15 13:56:50 +00:00
Aman Harwara 68991abba7 feat: add smart view with custom json (#2000) 2022-11-15 18:58:13 +05:30
Mo 7732f55a28 chore: remove slack (#1999) 2022-11-14 12:01:37 -06:00
StandardNotes CI 97dedc8523 chore(release): publish
- @standardnotes/[email protected].1
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].9
 - @standardnotes/[email protected].9
 - @standardnotes/[email protected].9
 - @standardnotes/[email protected].3
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2022-11-14 14:33:18 +00:00
Aman Harwara f656185c16 feat: GUI to create smart views (#1997) 2022-11-14 19:40:00 +05:30
Mo 1c23bc1747 fix(mobile): iOS IAP related adjustments 2022-11-14 07:50:50 -06:00
StandardNotes CI eba8dbc38c chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
2022-11-13 21:33:32 +00:00
Mo f42657fa9e fix: show files option in linking menu even if not entitled 2022-11-13 15:10:57 -06:00
StandardNotes CI 2b76d48392 chore(release): publish
- @standardnotes/[email protected].1
 - @standardnotes/[email protected]
2022-11-13 17:44:39 +00:00
Mo 5777e511a9 fix(mobile): android build 2022-11-13 11:16:47 -06:00
91 changed files with 1930 additions and 385 deletions
+1 -2
View File
@@ -3,7 +3,7 @@
Standard Notes is an end-to-end encrypted note-taking app for digitalists and professionals. Capture your notes, files, and lifes work all in one secure place.
[![latest release version](https://img.shields.io/github/v/release/standardnotes/app)](https://github.com/standardnotes/app/releases)
[![Slack](https://img.shields.io/badge/slack-standardnotes-CC2B5E.svg?style=flat&logo=slack)](https://standardnotes.com/slack)
[![Discord](https://img.shields.io/badge/discord-standardnotes-CC2B5E.svg?style=flat&logo=discord)](https://standardnotes.com/discord)
[![Twitter Follow](https://img.shields.io/badge/follow-%40standardnotes-blue.svg?style=flat&logo=twitter)](https://twitter.com/standardnotes)
### Why Standard Notes?
@@ -38,7 +38,6 @@ Join us on
- [Discord](https://standardnotes.com/discord)
- [Twitter](https://twitter.com/StandardNotes)
- [Forum](https://forum.standardnotes.org)
- [Slack](https://standardnotes.com/slack)
Developers can create and publish their own extensions. Visit the [documentation hub](https://docs.standardnotes.com/) to learn more.
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.20.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/api
# [1.20.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/api",
"version": "1.20.0",
"version": "1.20.1",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+12
View File
@@ -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.
## [3.23.297](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.296](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.295](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.294](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
**Note:** Version bump only for package @standardnotes/desktop
-1
View File
@@ -2,7 +2,6 @@
<div align="center">
[![Slack](https://img.shields.io/badge/slack-standardnotes-CC2B5E.svg?style=flat&logo=slack)](https://standardnotes.com/slack)
[![Twitter Follow](https://img.shields.io/badge/follow-%40standardnotes-blue.svg?style=flat&logo=twitter)](https://twitter.com/standardnotes)
</div>
@@ -238,7 +238,6 @@ const Urls = {
Support: 'mailto:[email protected]',
Website: 'https://standardnotes.com',
GitHub: 'https://github.com/standardnotes',
Slack: 'https://standardnotes.com/slack',
Discord: 'https://standardnotes.com/discord',
Twitter: 'https://twitter.com/StandardNotes',
GitHubReleases: 'https://github.com/standardnotes/app/releases',
@@ -604,12 +603,6 @@ function helpMenu(window: Electron.BrowserWindow, shell: Electron.Shell) {
void shell.openExternal(Urls.Discord)
},
},
{
label: str().slack,
click() {
void shell.openExternal(Urls.Slack)
},
},
{
label: str().twitter,
click() {
@@ -19,7 +19,6 @@ export function createEnglishStrings(): Strings {
website: 'Website',
gitHub: 'GitHub',
discord: 'Discord',
slack: 'Slack',
twitter: 'Twitter',
toggleErrorConsole: 'Toggle Error Console',
openDataDirectory: 'Open Data Directory',
@@ -24,7 +24,6 @@ interface AppMenuStrings {
website: string
gitHub: string
discord: string
slack: string
twitter: string
toggleErrorConsole: string
openDataDirectory: string
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@standardnotes/desktop",
"main": "./app/dist/index.js",
"version": "3.23.294",
"version": "3.23.297",
"license": "AGPL-3.0-or-later",
"author": "Standard Notes.",
"private": true,
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [0.2.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
**Note:** Version bump only for package @standardnotes/docs
## [0.2.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-10-05)
### Bug Fixes
+1 -1
View File
@@ -17,7 +17,7 @@ Thank you for your interest in contributing to Standard Notes. We are very happy
1. Submit suggestions and feature ideas to our [community forum](https://forum.standardnotes.org). You can comment on the existing feature requests and subscribe to individual threads to follow their progress.
2. Report bugs or issues to our [community forum](https://forum.standardnotes.org).
3. Help others on the forum and our [Slack group](https://standardnotes.com/slack).
3. Help others on the forum and our [Discord group](https://standardnotes.com/discord).
4. Blog, tweet and share what you like about Standard Notes, what you use it for, and how it helps you.
We do things the old-fashioned way and rely on people like you telling other people like you about Standard Notes to sustain our efforts. Get a free month of Extended when your friend also joins Extended. Log into the [member dashboard](https://dashboard.standardnotes.com) to get your unique referral link. 🙂
@@ -138,6 +138,6 @@ There are an unlimited number of things you can build with components that do an
To see how we built [our own components](https://standardnotes.com/extensions), study the source code [available here](https://github.com/sn-extensions).
For questions on development, [post in the forum](https://forum.standardnotes.org) or [join our Slack](https://standardnotes.com/slack).
For questions on development, [post in the forum](https://forum.standardnotes.org) or [join our Discord](https://standardnotes.com/discord).
If you'd like to support Standard Notes and use our secure hosting to install all the components we have to offer, consider purchasing the [Extended subscription](https://standardnotes.com/extended).
+1 -1
View File
@@ -98,4 +98,4 @@ Standard Notes will ping the `latest_url`endpoint automatically to update extens
## Questions?
Join the [Slack group](https://standardnotes.com/slack) and ask away in the \#dev channel.
Join the [Discord group](https://standardnotes.com/discord) and ask away in the \#dev channel.
@@ -155,8 +155,8 @@ For every received item:
## Next Steps
Join the [Slack group](https://standardnotes.com/slack) to discuss implementation details and ask any questions you may have.
Join the [Discord group](https://standardnotes.com/discord) to discuss implementation details and ask any questions you may have.
You can also email [[email protected]](mailto:help@standardnotes.org).
You can also email [[email protected]](mailto:help@standardnotes.com).
Follow [@standardnotes on Twitter](https://twitter.com/standardnotes) for updates and announcements.
@@ -338,8 +338,8 @@ result = ['004', nonce, ciphertext, encoded_authenticated_data].join(':')
## Next Steps
Join the [Slack group](https://standardnotes.com/slack) to discuss implementation details and ask any questions you may have.
Join the [Discord group](https://standardnotes.com/Discord) to discuss implementation details and ask any questions you may have.
You can also email [[email protected]](mailto:help@standardnotes.org).
You can also email [[email protected]](mailto:help@standardnotes.com).
Follow [@standardnotes on Twitter](https://twitter.com/standardnotes) for updates and announcements.
-1
View File
@@ -31,7 +31,6 @@ This documentation explains how to get started with using Standard Notes, publis
- [GitHub](https://github.com/standardnotes)
- [Discord](https://standardnotes.com/discord)
- [Twitter](https://twitter.com/standardnotes)
- [Slack](https://standardnotes.com/slack)
- [Blog](https://blog.standardnotes.org)
## Something missing?
+3 -3
View File
@@ -111,12 +111,12 @@ module.exports = {
title: 'Community',
items: [
{
label: 'Join our Slack',
href: 'https://standardnotes.com/slack',
label: 'Join our Discord',
href: 'https://standardnotes.com/discord',
},
{
label: 'Community Forum',
href: 'https://forum.standardnotes.org',
href: 'https://standardnotes.com/forum',
},
{
label: 'Listed Blogging Platform',
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/docs",
"version": "0.2.1",
"version": "0.2.2",
"license": "AGPL-3.0-or-later",
"author": "Standard Notes.",
"private": true,
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.19.9](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/encryption
## [1.19.8](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-09)
**Note:** Version bump only for package @standardnotes/encryption
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/encryption",
"version": "1.19.8",
"version": "1.19.9",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.25.9](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/filepicker
## [1.25.8](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-09)
**Note:** Version bump only for package @standardnotes/filepicker
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/filepicker",
"version": "1.25.8",
"version": "1.25.9",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.12.9](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/files
## [1.12.8](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-09)
**Note:** Version bump only for package @standardnotes/files
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/files",
"version": "1.12.8",
"version": "1.12.9",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+18
View File
@@ -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.
## [3.46.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
**Note:** Version bump only for package @standardnotes/mobile
## [3.46.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/mobile
## [3.46.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
**Note:** Version bump only for package @standardnotes/mobile
## [3.46.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
### Bug Fixes
* **mobile:** android build ([5777e51](https://github.com/standardnotes/app/commit/5777e511a95fb5c3f6c867b239d849290a71fcea))
# [3.46.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
### Features
-1
View File
@@ -4,7 +4,6 @@
[![Latest release version](https://img.shields.io/github/v/release/standardnotes/mobile)](https://github.com/standardnotes/mobile/releases)
[![License](https://img.shields.io/github/license/standardnotes/mobile?color=blue)](https://github.com/standardnotes/mobile/blob/master/LICENSE)
[![Slack](https://img.shields.io/badge/slack-standardnotes-CC2B5E.svg?style=flat&logo=slack)](https://standardnotes.com/slack)
[![Twitter Follow](https://img.shields.io/badge/follow-%40standardnotes-blue.svg?style=flat&logo=twitter)](https://twitter.com/standardnotes)
</div>
+1
View File
@@ -149,6 +149,7 @@ android {
testBuildType System.getProperty('testBuildType', 'debug') // detox: This will later be used to control the test apk build type
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
missingDimensionStrategy 'store', 'play'
}
signingConfigs {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/mobile",
"version": "3.46.0",
"version": "3.46.4",
"author": "Standard Notes.",
"private": true,
"license": "AGPL-3.0-or-later",
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.35.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
### Features
* GUI to create smart views ([#1997](https://github.com/standardnotes/app/issues/1997)) ([f656185](https://github.com/standardnotes/app/commit/f656185c167b3306d408dc486970836efa1d70c5))
## [1.34.10](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-09)
**Note:** Version bump only for package @standardnotes/models
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/models",
"version": "1.34.10",
"version": "1.35.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -15,8 +15,7 @@ export interface PredicateJsonForm {
export const AllPredicateCompoundOperators = ['and', 'or'] as const
export type PredicateCompoundOperator = typeof AllPredicateCompoundOperators[number]
export const AllPredicateOperators = [
...AllPredicateCompoundOperators,
export const AllNonCompoundPredicateOperators = [
'!=',
'=',
'<',
@@ -30,6 +29,8 @@ export const AllPredicateOperators = [
'includes',
] as const
export const AllPredicateOperators = [...AllPredicateCompoundOperators, ...AllNonCompoundPredicateOperators] as const
export type PredicateOperator = typeof AllPredicateOperators[number]
export type SureValue = number | number[] | string[] | string | Date | boolean | false | ''
@@ -23,6 +23,10 @@ export function valueMatchesTargetValue(
value = value.toLowerCase()
}
if (value instanceof Date && typeof targetValue === 'string') {
targetValue = new Date(targetValue)
}
if (operator === 'not') {
return !valueMatchesTargetValue(value, '=', targetValue)
}
@@ -429,6 +429,36 @@ describe('predicates', () => {
it('hours ago value', () => {
expect(new Predicate('updated_at', '>', '1.hours.ago').matchesItem(item)).toEqual(true)
})
it('nonmatching hours ago value', () => {
expect(new Predicate('updated_at', '<', '1.hours.ago').matchesItem(item)).toEqual(false)
})
it('months ago value', () => {
expect(new Predicate('updated_at', '>', '1.months.ago').matchesItem(item)).toEqual(true)
})
it('nonmatching months ago value', () => {
expect(new Predicate('updated_at', '<', '1.months.ago').matchesItem(item)).toEqual(false)
})
it('years ago value', () => {
expect(new Predicate('updated_at', '>', '1.years.ago').matchesItem(item)).toEqual(true)
})
it('nonmatching years ago value', () => {
expect(new Predicate('updated_at', '<', '1.years.ago').matchesItem(item)).toEqual(false)
})
it('string date value', () => {
item = createItem({}, new Date('01/01/2022'))
expect(new Predicate('updated_at', '<', '01/02/2022').matchesItem(item)).toEqual(true)
})
it('nonmatching string date value', () => {
item = createItem({}, new Date('01/01/2022'))
expect(new Predicate('updated_at', '>', '01/02/2022').matchesItem(item)).toEqual(false)
})
})
describe('nonexistent properties', () => {
@@ -0,0 +1,25 @@
import { dateFromDSLDateString } from './Utils'
describe('Predicate Utils', () => {
describe('dateFromDSLDateString', () => {
it('should return a date object with the correct day', () => {
const date = dateFromDSLDateString('1.days.ago')
expect(date.getDate()).toEqual(new Date().getDate() - 1)
})
it('should return a date object with the correct hour', () => {
const date = dateFromDSLDateString('1.hours.ago')
expect(date.getHours()).toEqual(new Date().getHours() - 1)
})
it('should return a date object with the correct month', () => {
const date = dateFromDSLDateString('1.months.ago')
expect(date.getMonth()).toEqual(new Date().getMonth() - 1)
})
it('should return a date object with the correct year', () => {
const date = dateFromDSLDateString('1.years.ago')
expect(date.getFullYear()).toEqual(new Date().getFullYear() - 1)
})
})
})
@@ -10,6 +10,10 @@ export function dateFromDSLDateString(string: string): Date {
date.setDate(date.getDate() - offset)
} else if (unit === 'hours') {
date.setHours(date.getHours() - offset)
} else if (unit === 'months') {
date.setMonth(date.getMonth() - offset)
} else if (unit === 'years') {
date.setFullYear(date.getFullYear() - offset)
}
return date
}
+16
View File
@@ -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.229](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.228](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.227](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.226](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.225](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
**Note:** Version bump only for package @standardnotes/releases
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/releases",
"version": "1.3.225",
"version": "1.3.229",
"license": "AGPL-3.0-or-later",
"main": "dist/releases.json",
"types": "dist/index.d.ts",
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.44.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
### Features
* GUI to create smart views ([#1997](https://github.com/standardnotes/app/issues/1997)) ([f656185](https://github.com/standardnotes/app/commit/f656185c167b3306d408dc486970836efa1d70c5))
# [1.43.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/services",
"version": "1.43.0",
"version": "1.44.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -148,4 +148,10 @@ export interface ItemsClientInterface {
* @returns Whether the item is a template (unmanaged)
*/
isTemplateItem(item: DecryptedItemInterface): boolean
createSmartView<T extends DecryptedItemInterface<ItemContent>>(
title: string,
predicate: PredicateInterface<T>,
iconString?: string,
): Promise<SmartView>
}
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [2.149.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
### Features
* GUI to create smart views ([#1997](https://github.com/standardnotes/app/issues/1997)) ([f656185](https://github.com/standardnotes/app/commit/f656185c167b3306d408dc486970836efa1d70c5))
# [2.148.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
### Features
@@ -1222,12 +1222,14 @@ export class ItemManager
public async createSmartView<T extends Models.DecryptedItemInterface>(
title: string,
predicate: Models.PredicateInterface<T>,
iconString?: string,
): Promise<Models.SmartView> {
return this.createItem(
ContentType.SmartView,
Models.FillItemContent({
title,
predicate: predicate.toJson(),
iconString: iconString || 'restore',
} as Models.SmartViewContent),
true,
) as Promise<Models.SmartView>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/snjs",
"version": "2.148.0",
"version": "2.149.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.13.11](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
**Note:** Version bump only for package @standardnotes/ui-services
## [1.13.10](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
**Note:** Version bump only for package @standardnotes/ui-services
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/ui-services",
"version": "1.13.10",
"version": "1.13.11",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+22
View File
@@ -3,6 +3,28 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [3.98.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-15)
### Features
* add smart view with custom json ([#2000](https://github.com/standardnotes/app/issues/2000)) ([68991ab](https://github.com/standardnotes/app/commit/68991abba7a2b32047ec7cc6402c3a8b5cc6ec52))
# [3.97.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-14)
### Bug Fixes
* **mobile:** iOS IAP related adjustments ([1c23bc1](https://github.com/standardnotes/app/commit/1c23bc1747a6701b56c54abea7c6d0a6e373d4ae))
### Features
* GUI to create smart views ([#1997](https://github.com/standardnotes/app/issues/1997)) ([f656185](https://github.com/standardnotes/app/commit/f656185c167b3306d408dc486970836efa1d70c5))
## [3.96.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
### Bug Fixes
* show files option in linking menu even if not entitled ([f42657f](https://github.com/standardnotes/app/commit/f42657fa9e01b3b8a4ca555ea5c06236503b40a4))
# [3.96.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-11-13)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/web",
"version": "3.96.0",
"version": "3.98.0",
"license": "AGPL-3.0-or-later",
"main": "dist/app.js",
"author": "Standard Notes.",
@@ -39,6 +39,7 @@ import { AndroidBackHandler } from '@/NativeMobileWeb/AndroidBackHandler'
import { PrefDefaults } from '@/Constants/PrefDefaults'
import { setCustomViewportHeight } from '@/setViewportHeightWithFallback'
import { WebServices } from './WebServices'
import { FeatureName } from '@/Controllers/FeatureName'
export type WebEventObserver = (event: WebAppEvent, data?: unknown) => void
@@ -204,6 +205,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter
return this.isNativeMobileWeb() && this.platform === Platform.Ios
}
get hideOutboundSubscriptionLinks() {
return this.isNativeIOS()
}
mobileDevice(): MobileDeviceInterface {
if (!this.isNativeMobileWeb()) {
throw Error('Attempting to access device as mobile device on non mobile platform')
@@ -347,6 +352,14 @@ export class WebApplication extends SNApplication implements WebApplicationInter
return this.hasValidSubscription()
}
get entitledToFiles(): boolean {
return this.getViewControllerManager().featuresController.entitledToFiles
}
showPremiumModal(featureName: FeatureName): void {
void this.getViewControllerManager().featuresController.showPremiumAlert(featureName)
}
hasValidSubscription(): boolean {
return this.getViewControllerManager().subscriptionController.hasValidSubscription()
}
@@ -3,7 +3,7 @@ import Icon from '@/Components/Icon/Icon'
import { IconType } from '@standardnotes/snjs'
type Props = {
onClick: () => void
onClick: MouseEventHandler<HTMLButtonElement>
className?: string
icon: IconType
iconClassName?: string
@@ -21,9 +21,9 @@ const IconButton: FunctionComponent<Props> = ({
iconClassName = '',
disabled = false,
}) => {
const click: MouseEventHandler = (e) => {
const click: MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault()
onClick()
onClick(e)
}
const focusableClass = focusable ? '' : 'focus:shadow-none'
return (
@@ -28,6 +28,7 @@ import { useFileDragNDrop } from '../FileDragNDropProvider/FileDragNDropProvider
import { LinkingController } from '@/Controllers/LinkingController'
import DailyContentList from './Daily/DailyContentList'
import { ListableContentItem } from './Types/ListableContentItem'
import { FeatureName } from '@/Controllers/FeatureName'
type Props = {
accountMenuController: AccountMenuController
@@ -123,6 +124,11 @@ const ContentListView: FunctionComponent<Props> = ({
const addNewItem = useCallback(async () => {
if (isFilesSmartView) {
if (!application.entitledToFiles) {
application.showPremiumModal(FeatureName.Files)
return
}
if (StreamingFileReader.available()) {
void filesController.uploadNewFile()
return
@@ -133,7 +139,7 @@ const ContentListView: FunctionComponent<Props> = ({
await createNewNote()
toggleAppPane(AppPaneId.Editor)
}
}, [isFilesSmartView, filesController, createNewNote, toggleAppPane])
}, [isFilesSmartView, filesController, createNewNote, toggleAppPane, application])
useEffect(() => {
/**
@@ -153,7 +153,7 @@ const ContentListHeader = ({
}, [OptionsMenu, AddButton, FolderName])
return (
<div className="section-title-bar-header items-start gap-1 overflow-hidden">
<div className="section-title-bar-header items-start gap-1">
{!isTablet && PhoneAndDesktopLayout}
{isTablet && TabletLayout}
</div>
@@ -20,9 +20,8 @@ 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'
import NoSubscriptionBanner from '@/Components/NoSubscriptionBanner/NoSubscriptionBanner'
const DailyEntryModeEnabled = true
@@ -200,30 +199,6 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
)
}
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">
{DailyEntryModeEnabled &&
'Create powerful workflows and organizational layouts with per-tag display preferences and the all-new Daily Notebook calendar layout.'}
{!DailyEntryModeEnabled &&
'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-base font-semibold uppercase text-text lg:text-xs">Preferences for</div>
@@ -239,7 +214,18 @@ const DisplayOptionsMenu: FunctionComponent<DisplayOptionsMenuProps> = ({
)}
</div>
{controlsDisabled && <NoSubscriptionBanner />}
{controlsDisabled && (
<NoSubscriptionBanner
className="m-2 mt-2 mb-3"
application={application}
title="Upgrade for per-tag preferences"
message={
DailyEntryModeEnabled
? 'Create powerful workflows and organizational layouts with per-tag display preferences and the all-new Daily Notebook calendar layout.'
: 'Create powerful workflows and organizational layouts with per-tag display preferences.'
}
/>
)}
<MenuItemSeparator />
@@ -188,7 +188,7 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
resetState()
if (!featuresController.hasFiles) {
if (!featuresController.entitledToFiles) {
premiumModal.activate('Files')
return
}
@@ -217,7 +217,7 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
dragCounter.current = 0
}
},
[application, featuresController.hasFiles, filesController, premiumModal, resetState],
[application, featuresController.entitledToFiles, filesController, premiumModal, resetState],
)
useEffect(() => {
@@ -9,6 +9,7 @@ export const IconNameToSvgMapping = {
'attachment-file': icons.AttachmentFileIcon,
'check-bold': icons.CheckBoldIcon,
'check-circle': icons.CheckCircleIcon,
'chevron-up': icons.ChevronUpIcon,
'chevron-down': icons.ChevronDownIcon,
'chevron-left': icons.ChevronLeftIcon,
'chevron-right': icons.ChevronRightIcon,
@@ -1,9 +1,10 @@
import { classNames } from '@/Utils/ConcatenateClassNames'
import { EmojiString, Platform, VectorIconNameOrEmoji } from '@standardnotes/snjs'
import { FunctionComponent, useMemo, useRef, useState } from 'react'
import Dropdown from '../Dropdown/Dropdown'
import { DropdownItem } from '../Dropdown/DropdownItem'
import { getEmojiLength } from './EmojiLength'
import { isIconEmoji } from './Icon'
import Icon, { isIconEmoji } from './Icon'
import { IconNameToSvgMapping } from './IconNameToSvgMapping'
import { IconPickerType } from './IconPickerType'
@@ -11,13 +12,26 @@ type Props = {
selectedValue: VectorIconNameOrEmoji
onIconChange: (value?: string) => void
platform: Platform
useIconGrid?: boolean
iconGridClassName?: string
portalDropdown?: boolean
className?: string
}
const IconPicker = ({ selectedValue, onIconChange, platform, className }: Props) => {
const IconPicker = ({
selectedValue,
onIconChange,
platform,
className,
useIconGrid,
portalDropdown,
iconGridClassName,
}: Props) => {
const iconKeys = useMemo(() => Object.keys(IconNameToSvgMapping), [])
const iconOptions = useMemo(
() =>
[...Object.keys(IconNameToSvgMapping)].map(
iconKeys.map(
(value) =>
({
label: value,
@@ -25,7 +39,7 @@ const IconPicker = ({ selectedValue, onIconChange, platform, className }: Props)
icon: value,
} as DropdownItem),
),
[],
[iconKeys],
)
const isSelectedEmoji = isIconEmoji(selectedValue)
@@ -91,16 +105,36 @@ const IconPicker = ({ selectedValue, onIconChange, platform, className }: Props)
<TabButton label="Reset" type={'reset'} />
</div>
<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}
value={selectedValue}
onChange={handleIconChange}
/>
)}
{currentType === 'icon' &&
(useIconGrid ? (
<div
className={classNames(
'flex w-full flex-wrap items-center gap-6 p-1 md:max-h-24 md:gap-4 md:p-0',
iconGridClassName,
)}
>
{iconKeys.map((iconName) => (
<button
key={iconName}
onClick={() => {
handleIconChange(iconName)
}}
>
<Icon type={iconName} />
</button>
))}
</div>
) : (
<Dropdown
fullWidth={true}
id="change-tag-icon-dropdown"
label="Change the icon for a tag"
items={iconOptions}
value={selectedValue}
onChange={handleIconChange}
portal={portalDropdown}
/>
))}
{currentType === 'emoji' && (
<>
<div>
@@ -1,156 +1,17 @@
import { FeatureName } from '@/Controllers/FeatureName'
import { FeaturesController } from '@/Controllers/FeaturesController'
import { FilesController } from '@/Controllers/FilesController'
import { LinkingController } from '@/Controllers/LinkingController'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { formatDateForContextMenu } from '@/Utils/DateUtils'
import { getIconForItem } from '@/Utils/Items/Icons/getIconForItem'
import { getLinkingSearchResults } from '@/Utils/Items/Search/getSearchResults'
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { FileItem } from '@standardnotes/snjs'
import { KeyboardKey } from '@standardnotes/ui-services'
import { observer } from 'mobx-react-lite'
import { ChangeEventHandler, useEffect, useRef, useState } from 'react'
import { useApplication } from '../ApplicationView/ApplicationProvider'
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import ClearInputButton from '../ClearInputButton/ClearInputButton'
import Icon from '../Icon/Icon'
import DecoratedInput from '../Input/DecoratedInput'
import MenuItem from '../Menu/MenuItem'
import { MenuItemType } from '../Menu/MenuItemType'
import Popover from '../Popover/Popover'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import LinkedFileMenuOptions from './LinkedFileMenuOptions'
import LinkedItemMeta from './LinkedItemMeta'
import LinkedItemSearchResults from './LinkedItemSearchResults'
const LinkedItemsSectionItem = ({
activateItem,
item,
searchQuery,
unlinkItem,
handleFileAction,
}: {
activateItem: LinkingController['activateItem']
item: LinkableItem
searchQuery?: string
unlinkItem: () => void
handleFileAction: FilesController['handleFileAction']
}) => {
const menuButtonRef = useRef<HTMLButtonElement>(null)
const application = useApplication()
const [isMenuOpen, setIsMenuOpen] = useState(false)
const toggleMenu = () => setIsMenuOpen((open) => !open)
const [isRenamingFile, setIsRenamingFile] = useState(false)
const [icon, className] = getIconForItem(item, application)
const title = item.title ?? ''
const renameFile = async (name: string) => {
if (!(item instanceof FileItem)) {
return
}
await handleFileAction({
type: PopoverFileItemActionType.RenameFile,
payload: {
file: item,
name: name,
},
})
setIsRenamingFile(false)
}
return (
<div className="relative flex items-center justify-between">
{isRenamingFile && item instanceof FileItem ? (
<div className="flex flex-grow items-center gap-4 py-2 pl-3 pr-12">
<Icon type={icon} className={classNames('flex-shrink-0', className)} />
<input
className="min-w-0 flex-grow text-sm"
defaultValue={title}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
setIsRenamingFile(false)
} else if (event.key === KeyboardKey.Enter) {
const newTitle = event.currentTarget.value
void renameFile(newTitle)
}
}}
ref={(node) => {
if (node) {
node.focus()
}
}}
/>
</div>
) : (
<button
className="flex max-w-full flex-grow items-center justify-between gap-4 py-2 pl-3 pr-12 text-sm hover:bg-info-backdrop focus:bg-info-backdrop"
onClick={() => activateItem(item)}
onContextMenu={(event) => {
event.preventDefault()
toggleMenu()
}}
>
<LinkedItemMeta item={item} searchQuery={searchQuery} />
</button>
)}
<button
className="absolute right-3 top-1/2 h-7 w-7 -translate-y-1/2 cursor-pointer rounded-full border-0 bg-transparent p-1 hover:bg-contrast"
onClick={toggleMenu}
ref={menuButtonRef}
>
<Icon type="more" className="text-neutral" />
</button>
<Popover
open={isMenuOpen}
togglePopover={toggleMenu}
anchorElement={menuButtonRef.current}
side="bottom"
align="center"
className="py-2"
>
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
unlinkItem()
toggleMenu()
}}
>
<Icon type="link-off" className="mr-2 text-danger" />
Unlink
</MenuItem>
{item instanceof FileItem && (
<LinkedFileMenuOptions
file={item}
closeMenu={toggleMenu}
handleFileAction={handleFileAction}
setIsRenamingFile={setIsRenamingFile}
/>
)}
<HorizontalSeparator classes="my-2" />
<div className="mt-1 px-3 py-1 text-xs font-medium text-neutral">
<div className="mb-1">
<span className="font-semibold">Created at:</span> {formatDateForContextMenu(item.created_at)}
</div>
<div className="mb-1">
<span className="font-semibold">Modified at:</span> {formatDateForContextMenu(item.userModifiedDate)}
</div>
<div className="mb-1">
<span className="font-semibold">ID:</span> {item.uuid}
</div>
{item instanceof FileItem && (
<div>
<span className="font-semibold">Size:</span> {formatSizeToReadableString(item.decryptedSize)}
</div>
)}
</div>
</Popover>
</div>
)
}
import { LinkedItemsSectionItem } from './LinkedItemsSectionItem'
const LinkedItemsPanel = ({
linkingController,
@@ -178,7 +39,7 @@ const LinkedItemsPanel = ({
activeItem,
} = linkingController
const { hasFiles } = featuresController
const { entitledToFiles } = featuresController
const application = useApplication()
const fileInputRef = useRef<HTMLInputElement | null>(null)
@@ -214,6 +75,11 @@ const LinkedItemsPanel = ({
}
const selectAndUploadFiles = () => {
if (!entitledToFiles) {
void featuresController.showPremiumAlert(FeatureName.Files)
return
}
if (!fileInputRef.current) {
return
}
@@ -307,37 +173,37 @@ const LinkedItemsPanel = ({
</div>
</div>
)}
{(!!linkedFiles.length || hasFiles) && (
<div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Files</div>
<div className="my-1">
<input
type="file"
className="absolute top-0 left-0 -z-50 h-px w-px opacity-0"
multiple
ref={fileInputRef}
onChange={handleFileInputChange}
<div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">Linked Files</div>
<div className="my-1">
<input
type="file"
className="absolute top-0 left-0 -z-50 h-px w-px opacity-0"
multiple
ref={fileInputRef}
onChange={handleFileInputChange}
/>
<button
className="flex w-full cursor-pointer items-center gap-3 bg-transparent px-3 py-2 text-left text-base text-text hover:bg-info-backdrop hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-sm"
onClick={selectAndUploadFiles}
>
<Icon type="add" />
Upload and link file(s)
</button>
{linkedFiles.map((link) => (
<LinkedItemsSectionItem
key={link.id}
item={link.item}
searchQuery={searchQuery}
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
activateItem={activateItem}
handleFileAction={filesController.handleFileAction}
/>
<button
className="flex w-full cursor-pointer items-center gap-3 bg-transparent px-3 py-2 text-left text-base text-text hover:bg-info-backdrop hover:text-foreground focus:bg-info-backdrop focus:shadow-none md:text-sm"
onClick={selectAndUploadFiles}
>
<Icon type="add" />
Upload and link file(s)
</button>
{linkedFiles.map((link) => (
<LinkedItemsSectionItem
key={link.id}
item={link.item}
searchQuery={searchQuery}
unlinkItem={() => unlinkItemFromSelectedItem(link.item)}
activateItem={activateItem}
handleFileAction={filesController.handleFileAction}
/>
))}
</div>
))}
</div>
)}
</div>
{!!filesLinkingToActiveItem.length && (
<div>
<div className="mt-3 mb-1 px-3 text-menu-item font-semibold uppercase text-passive-0">
@@ -0,0 +1,147 @@
import { FilesController } from '@/Controllers/FilesController'
import { LinkingController } from '@/Controllers/LinkingController'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { formatDateForContextMenu } from '@/Utils/DateUtils'
import { getIconForItem } from '@/Utils/Items/Icons/getIconForItem'
import { LinkableItem } from '@/Utils/Items/Search/LinkableItem'
import { formatSizeToReadableString } from '@standardnotes/filepicker'
import { FileItem } from '@standardnotes/snjs'
import { KeyboardKey } from '@standardnotes/ui-services'
import { useRef, useState } from 'react'
import { useApplication } from '../ApplicationView/ApplicationProvider'
import { PopoverFileItemActionType } from '../AttachedFilesPopover/PopoverFileItemAction'
import Icon from '../Icon/Icon'
import MenuItem from '../Menu/MenuItem'
import { MenuItemType } from '../Menu/MenuItemType'
import Popover from '../Popover/Popover'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import LinkedFileMenuOptions from './LinkedFileMenuOptions'
import LinkedItemMeta from './LinkedItemMeta'
export const LinkedItemsSectionItem = ({
activateItem,
item,
searchQuery,
unlinkItem,
handleFileAction,
}: {
activateItem: LinkingController['activateItem']
item: LinkableItem
searchQuery?: string
unlinkItem: () => void
handleFileAction: FilesController['handleFileAction']
}) => {
const menuButtonRef = useRef<HTMLButtonElement>(null)
const application = useApplication()
const [isMenuOpen, setIsMenuOpen] = useState(false)
const toggleMenu = () => setIsMenuOpen((open) => !open)
const [isRenamingFile, setIsRenamingFile] = useState(false)
const [icon, className] = getIconForItem(item, application)
const title = item.title ?? ''
const renameFile = async (name: string) => {
if (!(item instanceof FileItem)) {
return
}
await handleFileAction({
type: PopoverFileItemActionType.RenameFile,
payload: {
file: item,
name: name,
},
})
setIsRenamingFile(false)
}
return (
<div className="relative flex items-center justify-between">
{isRenamingFile && item instanceof FileItem ? (
<div className="flex flex-grow items-center gap-4 py-2 pl-3 pr-12">
<Icon type={icon} className={classNames('flex-shrink-0', className)} />
<input
className="min-w-0 flex-grow text-sm"
defaultValue={title}
onKeyDown={(event) => {
if (event.key === KeyboardKey.Escape) {
setIsRenamingFile(false)
} else if (event.key === KeyboardKey.Enter) {
const newTitle = event.currentTarget.value
void renameFile(newTitle)
}
}}
ref={(node) => {
if (node) {
node.focus()
}
}}
/>
</div>
) : (
<button
className="flex max-w-full flex-grow items-center justify-between gap-4 py-2 pl-3 pr-12 text-sm hover:bg-info-backdrop focus:bg-info-backdrop"
onClick={() => activateItem(item)}
onContextMenu={(event) => {
event.preventDefault()
toggleMenu()
}}
>
<LinkedItemMeta item={item} searchQuery={searchQuery} />
</button>
)}
<button
className="absolute right-3 top-1/2 h-7 w-7 -translate-y-1/2 cursor-pointer rounded-full border-0 bg-transparent p-1 hover:bg-contrast"
onClick={toggleMenu}
ref={menuButtonRef}
>
<Icon type="more" className="text-neutral" />
</button>
<Popover
open={isMenuOpen}
togglePopover={toggleMenu}
anchorElement={menuButtonRef.current}
side="bottom"
align="center"
className="py-2"
>
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
unlinkItem()
toggleMenu()
}}
>
<Icon type="link-off" className="mr-2 text-danger" />
Unlink
</MenuItem>
{item instanceof FileItem && (
<LinkedFileMenuOptions
file={item}
closeMenu={toggleMenu}
handleFileAction={handleFileAction}
setIsRenamingFile={setIsRenamingFile}
/>
)}
<HorizontalSeparator classes="my-2" />
<div className="mt-1 px-3 py-1 text-xs font-medium text-neutral">
<div className="mb-1">
<span className="font-semibold">Created at:</span> {formatDateForContextMenu(item.created_at)}
</div>
<div className="mb-1">
<span className="font-semibold">Modified at:</span> {formatDateForContextMenu(item.userModifiedDate)}
</div>
<div className="mb-1">
<span className="font-semibold">ID:</span> {item.uuid}
</div>
{item instanceof FileItem && (
<div>
<span className="font-semibold">Size:</span> {formatSizeToReadableString(item.decryptedSize)}
</div>
)}
</div>
</Popover>
</div>
)
}
@@ -0,0 +1,35 @@
import { WebApplication } from '@/Application/Application'
import { classNames } from '@/Utils/ConcatenateClassNames'
import Button from '../Button/Button'
import Icon from '../Icon/Icon'
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
const NoSubscriptionBanner = ({
application,
title,
message,
className,
}: {
application: WebApplication
title: string
message: string
className?: string
}) => (
<div className={classNames('grid grid-cols-1 rounded-md border border-border p-4', className)}>
<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">{title}</h1>
</div>
<p className="col-start-1 col-end-3 m-0 mt-1 text-sm">{message}</p>
<Button
primary
small
className="col-start-1 col-end-3 mt-3 justify-self-start uppercase"
onClick={() => application.openPurchaseFlow()}
>
Upgrade Features
</Button>
</div>
)
export default NoSubscriptionBanner
@@ -29,7 +29,7 @@ const AccountPreferences = ({ application, viewControllerManager }: Props) => (
)}
<Subscription application={application} viewControllerManager={viewControllerManager} />
<SubscriptionSharing application={application} viewControllerManager={viewControllerManager} />
{application.hasAccount() && viewControllerManager.featuresController.hasFiles && (
{application.hasAccount() && viewControllerManager.featuresController.entitledToFiles && (
<FilesSection application={application} />
)}
{application.hasAccount() && <Email application={application} />}
@@ -29,7 +29,9 @@ const NoSubscription: FunctionComponent<Props> = ({ application }) => {
{isLoadingPurchaseFlow && <Text>Redirecting you to the subscription page...</Text>}
{purchaseFlowError && <Text className="text-danger">{purchaseFlowError}</Text>}
<div className="flex">
<LinkButton className="mt-3 mr-3 min-w-20" label="Learn More" link={window.plansUrl as string} />
{!application.hideOutboundSubscriptionLinks && (
<LinkButton className="mt-3 mr-3 min-w-20" label="Learn More" link={window.plansUrl as string} />
)}
{application.hasAccount() && (
<Button className="mt-3 min-w-20" primary label="Subscribe" onClick={onPurchaseClick} />
)}
@@ -33,7 +33,9 @@ const NoProSubscription: FunctionComponent<Props> = ({ application }) => {
{purchaseFlowError && <Text className="text-danger">{purchaseFlowError}</Text>}
<div className="flex">
<LinkButton className="mt-3 mr-3 min-w-20" label="Learn More" link={window.plansUrl as string} />
{!application.hideOutboundSubscriptionLinks && (
<LinkButton className="mt-3 mr-3 min-w-20" label="Learn More" link={window.plansUrl as string} />
)}
{application.hasAccount() && (
<Button className="mt-3 min-w-20" primary label="Upgrade" onClick={onPurchaseClick} />
)}
@@ -65,7 +65,6 @@ const CloudLink: FunctionComponent = () => (
Want to meet other passionate note-takers and privacy enthusiasts? Want to share your feedback with us? Join
the Standard Notes community groups for discussions on security, themes, editors and more.
</Text>
<LinkButton className="mt-3" link="https://standardnotes.com/slack" label="Join our Slack" />
<LinkButton className="mt-3" link="https://standardnotes.com/discord" label="Join our Discord" />
</PreferencesSegment>
</PreferencesGroup>
@@ -10,6 +10,7 @@ import Advanced from '@/Components/Preferences/Panes/General/Advanced/AdvancedSe
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
import PlaintextDefaults from './PlaintextDefaults'
import Persistence from './Persistence'
import SmartViews from './SmartViews/SmartViews'
type Props = {
viewControllerManager: ViewControllerManager
@@ -22,6 +23,11 @@ const General: FunctionComponent<Props> = ({ viewControllerManager, application,
<Persistence application={application} />
<PlaintextDefaults application={application} />
<Defaults application={application} />
<SmartViews
application={application}
navigationController={viewControllerManager.navigationController}
featuresController={viewControllerManager.featuresController}
/>
<Tools application={application} />
<LabsPane application={application} />
<Advanced
@@ -0,0 +1,128 @@
import { WebApplication } from '@/Application/Application'
import Button from '@/Components/Button/Button'
import Icon from '@/Components/Icon/Icon'
import IconPicker from '@/Components/Icon/IconPicker'
import Popover from '@/Components/Popover/Popover'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import Spinner from '@/Components/Spinner/Spinner'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { SmartView, TagMutator } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useCallback, useRef, useState } from 'react'
type Props = {
application: WebApplication
navigationController: NavigationController
view: SmartView
closeDialog: () => void
}
const EditSmartViewModal = ({ application, navigationController, view, closeDialog }: Props) => {
const [title, setTitle] = useState(view.title)
const titleInputRef = useRef<HTMLInputElement>(null)
const [selectedIcon, setSelectedIcon] = useState<string | undefined>(view.iconString)
const [isSaving, setIsSaving] = useState(false)
const [shouldShowIconPicker, setShouldShowIconPicker] = useState(false)
const iconPickerButtonRef = useRef<HTMLButtonElement>(null)
const toggleIconPicker = useCallback(() => {
setShouldShowIconPicker((shouldShow) => !shouldShow)
}, [])
const saveSmartView = useCallback(async () => {
if (!title.length) {
titleInputRef.current?.focus()
return
}
setIsSaving(true)
await application.mutator.changeAndSaveItem<TagMutator>(view, (mutator) => {
mutator.title = title
mutator.iconString = selectedIcon || 'restore'
})
setIsSaving(false)
closeDialog()
}, [application.mutator, closeDialog, selectedIcon, title, view])
const deleteSmartView = useCallback(async () => {
void navigationController.remove(view, true)
closeDialog()
}, [closeDialog, navigationController, view])
const close = useCallback(() => {
closeDialog()
}, [closeDialog])
return (
<ModalDialog>
<ModalDialogLabel closeDialog={close}>Edit Smart View "{view.title}"</ModalDialogLabel>
<ModalDialogDescription>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2.5">
<div className="text-sm font-semibold">Title:</div>
<input
className="rounded border border-border bg-default py-1 px-2"
value={title}
onChange={(event) => {
setTitle(event.target.value)
}}
ref={titleInputRef}
/>
</div>
<div className="flex items-center gap-2.5">
<div className="text-sm font-semibold">Icon:</div>
<button
className="rounded border border-border p-2"
aria-label="Change icon"
onClick={toggleIconPicker}
ref={iconPickerButtonRef}
>
<Icon type={selectedIcon || 'restore'} />
</button>
<Popover
open={shouldShowIconPicker}
anchorElement={iconPickerButtonRef.current}
togglePopover={toggleIconPicker}
align="start"
overrideZIndex="z-modal"
>
<div className="p-2">
<IconPicker
selectedValue={selectedIcon || 'restore'}
onIconChange={(value?: string | undefined) => {
setSelectedIcon(value)
toggleIconPicker()
}}
platform={application.platform}
useIconGrid={true}
portalDropdown={false}
/>
</div>
</Popover>
</div>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button className="mr-auto" disabled={isSaving} onClick={deleteSmartView} colorStyle="danger">
Delete
</Button>
<Button disabled={isSaving} onClick={saveSmartView}>
{isSaving ? <Spinner className="h-4.5 w-4.5" /> : 'Save'}
</Button>
<Button disabled={isSaving} onClick={close}>
Cancel
</Button>
</ModalDialogButtons>
</ModalDialog>
)
}
export default observer(EditSmartViewModal)
@@ -0,0 +1,26 @@
import Button from '@/Components/Button/Button'
import Icon from '@/Components/Icon/Icon'
import { SmartView } from '@standardnotes/snjs'
type Props = {
view: SmartView
onEdit: () => void
onDelete: () => void
}
const SmartViewItem = ({ view, onEdit, onDelete }: Props) => {
return (
<div className="flex items-center gap-2 py-1.5">
<Icon type={view.iconString} size="custom" className="h-5.5 w-5.5" />
<span className="mr-auto text-sm">{view.title}</span>
<Button small onClick={onEdit}>
Edit
</Button>
<Button small onClick={onDelete}>
Delete
</Button>
</div>
)
}
export default SmartViewItem
@@ -0,0 +1,85 @@
import { WebApplication } from '@/Application/Application'
import Button from '@/Components/Button/Button'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
import { isSystemView, SmartView } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useMemo, useState } from 'react'
import { Title } from '../../../PreferencesComponents/Content'
import PreferencesGroup from '../../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../../PreferencesComponents/PreferencesSegment'
import AddSmartViewModal from '@/Components/SmartViewBuilder/AddSmartViewModal'
import { AddSmartViewModalController } from '@/Components/SmartViewBuilder/AddSmartViewModalController'
import EditSmartViewModal from './EditSmartViewModal'
import SmartViewItem from './SmartViewItem'
import { FeaturesController } from '@/Controllers/FeaturesController'
import NoSubscriptionBanner from '@/Components/NoSubscriptionBanner/NoSubscriptionBanner'
type NewType = {
application: WebApplication
navigationController: NavigationController
featuresController: FeaturesController
}
type Props = NewType
const SmartViews = ({ application, navigationController, featuresController }: Props) => {
const [editingSmartView, setEditingSmartView] = useState<SmartView | undefined>(undefined)
const addSmartViewModalController = useMemo(() => new AddSmartViewModalController(application), [application])
const nonSystemSmartViews = navigationController.smartViews.filter((view) => !isSystemView(view))
return (
<>
<PreferencesGroup>
<PreferencesSegment>
<Title>Smart Views</Title>
{!featuresController.hasSmartViews && (
<NoSubscriptionBanner
className="mt-2"
application={application}
title="Upgrade for smart views"
message="Create smart views to organize your notes according to conditions you define."
/>
)}
{featuresController.hasSmartViews && (
<>
<div className="my-2 flex flex-col">
{nonSystemSmartViews.map((view) => (
<SmartViewItem
key={view.uuid}
view={view}
onEdit={() => setEditingSmartView(view)}
onDelete={() => navigationController.remove(view, true)}
/>
))}
</div>
<Button
onClick={() => {
addSmartViewModalController.setIsAddingSmartView(true)
}}
>
Create Smart View
</Button>
</>
)}
</PreferencesSegment>
</PreferencesGroup>
{!!editingSmartView && (
<EditSmartViewModal
application={application}
navigationController={navigationController}
view={editingSmartView}
closeDialog={() => {
setEditingSmartView(undefined)
}}
/>
)}
{addSmartViewModalController.isAddingSmartView && (
<AddSmartViewModal controller={addSmartViewModalController} platform={application.platform} />
)}
</>
)
}
export default observer(SmartViews)
@@ -41,6 +41,14 @@ const HelpAndFeedback = ({ application }: { application: WebApplication }) => {
Privacy Manifesto.
</a>
</Text>
{application.isNativeIOS() && (
<LinkButton
className="mt-3"
label="Privacy Policy"
link="https://standardnotes.com/privacy"
onClick={handleClick}
/>
)}
</PreferencesSegment>
<HorizontalSeparator classes="my-4" />
<PreferencesSegment>
@@ -1,14 +1,14 @@
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
import { FunctionComponent, useCallback, useRef } from 'react'
import Icon from '@/Components/Icon/Icon'
import { AlertDialog } from '@reach/alert-dialog'
import { FunctionComponent, useRef } from 'react'
import { WebApplication } from '@/Application/Application'
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
import { PremiumFeatureModalType } from './PremiumFeatureModalType'
import { FeatureName } from '@/Controllers/FeatureName'
import { SuccessPrompt } from './Subviews/SuccessPrompt'
import { UpgradePrompt } from './Subviews/UpgradePrompt'
type Props = {
application: WebApplication
featureName: string
featureName: FeatureName | string
hasSubscription: boolean
hasAccount: boolean
onClose: () => void
@@ -25,92 +25,32 @@ const PremiumFeaturesModal: FunctionComponent<Props> = ({
showModal,
type = PremiumFeatureModalType.UpgradePrompt,
}) => {
const plansButtonRef = useRef<HTMLButtonElement>(null)
const ctaButtonRef = useRef<HTMLButtonElement>(null)
const handleClick = useCallback(() => {
if (hasSubscription) {
void openSubscriptionDashboard(application)
} else if (hasAccount) {
void application.openPurchaseFlow()
} else if (window.plansUrl) {
window.location.assign(window.plansUrl)
}
onClose()
}, [application, hasSubscription, hasAccount, onClose])
if (!showModal) {
return null
}
const UpgradePrompt = (
<>
<AlertDialogDescription className="mb-2 px-4.5 text-center text-sm text-passive-1">
To take advantage of <span className="font-semibold">{featureName}</span> and other advanced features, upgrade
your current plan.
</AlertDialogDescription>
<div className="p-4">
<button
onClick={handleClick}
className="no-border w-full cursor-pointer rounded bg-info py-2 font-bold text-info-contrast hover:brightness-125 focus:brightness-125"
ref={plansButtonRef}
>
Upgrade
</button>
</div>
</>
)
const SuccessPrompt = (
<>
<AlertDialogDescription className="mb-2 px-4.5 text-center text-sm text-passive-1">
Enjoy your new powered up experience.
</AlertDialogDescription>
<div className="p-4">
<button
onClick={onClose}
className="no-border w-full cursor-pointer rounded bg-info py-2 font-bold text-info-contrast hover:brightness-125 focus:brightness-125"
ref={plansButtonRef}
>
Continue
</button>
</div>
</>
)
const title =
type === PremiumFeatureModalType.UpgradePrompt ? 'Enable Advanced Features' : 'Your purchase was successful!'
const iconName = type === PremiumFeatureModalType.UpgradePrompt ? PremiumFeatureIconName : '🎉'
const iconClass =
type === PremiumFeatureModalType.UpgradePrompt
? `h-12 w-12 ${PremiumFeatureIconClass}`
: 'px-7 py-2 h-24 w-24 text-[50px]'
return showModal ? (
<AlertDialog leastDestructiveRef={plansButtonRef} className="p-0">
return (
<AlertDialog leastDestructiveRef={ctaButtonRef} className="p-0">
<div tabIndex={-1} className="sn-component">
<div tabIndex={0} className="max-w-89 rounded bg-default p-4 shadow-main">
<AlertDialogLabel>
<div className="flex justify-end p-1">
<button
className="flex cursor-pointer border-0 bg-transparent p-0"
onClick={onClose}
aria-label="Close modal"
>
<Icon className="text-neutral" type="close" />
</button>
</div>
<div
className="mx-auto mb-5 flex h-24 w-24 items-center justify-center rounded-[50%] bg-contrast"
aria-hidden={true}
>
<Icon className={iconClass} size={'custom'} type={iconName} />
</div>
<div className="mb-1 text-center text-lg font-bold">{title}</div>
</AlertDialogLabel>
{type === PremiumFeatureModalType.UpgradePrompt ? UpgradePrompt : SuccessPrompt}
{type === PremiumFeatureModalType.UpgradePrompt && (
<UpgradePrompt
featureName={featureName}
ctaRef={ctaButtonRef}
application={application}
hasAccount={hasAccount}
hasSubscription={hasSubscription}
onClose={onClose}
/>
)}
{type === PremiumFeatureModalType.UpgradeSuccess && <SuccessPrompt ctaRef={ctaButtonRef} onClose={onClose} />}
</div>
</div>
</AlertDialog>
) : null
)
}
export default PremiumFeaturesModal
@@ -0,0 +1,47 @@
import Icon from '@/Components/Icon/Icon'
import { AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
export const SuccessPrompt = ({
ctaRef,
onClose,
}: {
ctaRef: React.RefObject<HTMLButtonElement>
onClose: () => void
}) => {
return (
<>
<AlertDialogLabel>
<div className="flex justify-end p-1">
<button
className="flex cursor-pointer border-0 bg-transparent p-0"
onClick={onClose}
aria-label="Close modal"
>
<Icon className="text-neutral" type="close" />
</button>
</div>
<div
className="mx-auto mb-5 flex h-24 w-24 items-center justify-center rounded-[50%] bg-contrast"
aria-hidden={true}
>
<Icon className={'h-24 w-24 px-7 py-2 text-[50px]'} size={'custom'} type={'🎉'} />
</div>
<div className="mb-1 text-center text-lg font-bold">Your purchase was successful!</div>
</AlertDialogLabel>
<AlertDialogDescription className="mb-2 px-4.5 text-center text-sm text-passive-1">
Enjoy your new powered up experience.
</AlertDialogDescription>
<div className="p-4">
<button
onClick={onClose}
className="no-border w-full cursor-pointer rounded bg-info py-2 font-bold text-info-contrast hover:brightness-125 focus:brightness-125"
ref={ctaRef}
>
Continue
</button>
</div>
</>
)
}
@@ -0,0 +1,82 @@
import { AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
import { useCallback } from 'react'
import { WebApplication } from '@/Application/Application'
import { openSubscriptionDashboard } from '@/Utils/ManageSubscription'
import Icon from '@/Components/Icon/Icon'
import { PremiumFeatureIconClass, PremiumFeatureIconName } from '@/Components/Icon/PremiumFeatureIcon'
export const UpgradePrompt = ({
featureName,
ctaRef,
application,
hasSubscription,
hasAccount,
onClose,
}: {
featureName: string
ctaRef: React.RefObject<HTMLButtonElement>
application: WebApplication
hasSubscription: boolean
hasAccount: boolean
onClose: () => void
}) => {
const handleClick = useCallback(() => {
if (hasSubscription) {
void openSubscriptionDashboard(application)
} else if (hasAccount) {
void application.openPurchaseFlow()
} else if (window.plansUrl) {
window.location.assign(window.plansUrl)
}
onClose()
}, [application, hasSubscription, hasAccount, onClose])
return (
<>
<AlertDialogLabel>
<div className="flex justify-end p-1">
<button
className="flex cursor-pointer border-0 bg-transparent p-0"
onClick={onClose}
aria-label="Close modal"
>
<Icon className="text-neutral" type="close" />
</button>
</div>
<div
className="mx-auto mb-5 flex h-24 w-24 items-center justify-center rounded-[50%] bg-contrast"
aria-hidden={true}
>
<Icon className={`h-12 w-12 ${PremiumFeatureIconClass}`} size={'custom'} type={PremiumFeatureIconName} />
</div>
<div className="mb-1 text-center text-lg font-bold">Enable Advanced Features</div>
</AlertDialogLabel>
<AlertDialogDescription className="mb-2 px-4.5 text-center text-sm text-passive-1">
To take advantage of <span className="font-semibold">{featureName}</span> and other advanced features, upgrade
your current plan.
{application.isNativeIOS() && (
<div className="mt-2">
<div className="mb-2 font-bold">The Professional Plan costs $99.99/year and includes benefits like</div>
<ul className="list-inside list-[circle]">
<li>100GB encrypted file storage</li>
<li>Access to all note types, including markdown, rich text, authenticator, tasks, and spreadsheets</li>
<li>Note history going back indefinitely</li>
<li>Nested folders for your tags</li>
<li>Premium support</li>
</ul>
</div>
)}
</AlertDialogDescription>
<div className="p-4">
<button
onClick={handleClick}
className="no-border w-full cursor-pointer rounded bg-info py-2 font-bold text-info-contrast hover:brightness-125 focus:brightness-125"
ref={ctaRef}
>
Upgrade
</button>
</div>
</>
)
}
@@ -0,0 +1,63 @@
import { classNames } from '@/Utils/ConcatenateClassNames'
import { addToast, ToastType } from '@standardnotes/toast'
import { useRef, useState } from 'react'
import Icon from '../Icon/Icon'
type Props = {
code: string
}
const CopyableCodeBlock = ({ code }: Props) => {
const buttonRef = useRef<HTMLButtonElement>(null)
const [didCopy, setDidCopy] = useState(false)
const [isCopyButtonVisible, setIsCopyButtonVisible] = useState(false)
return (
<div
className="group relative"
onMouseEnter={() => setIsCopyButtonVisible(true)}
onMouseLeave={() => setIsCopyButtonVisible(false)}
>
<pre className="overflow-auto rounded-md bg-default px-2.5 py-1.5">{code}</pre>
<div className="absolute top-1.5 right-1.5">
<button
ref={buttonRef}
className={classNames(
'peer rounded border border-border bg-default p-2 text-text hover:bg-contrast',
!isCopyButtonVisible && 'hidden',
)}
onClick={() => {
navigator.clipboard.writeText(code).then(
() => {
setDidCopy(true)
setTimeout(() => {
setDidCopy(false)
buttonRef.current?.blur()
}, 1000)
},
() => {
addToast({
type: ToastType.Error,
message: 'Failed to copy to clipboard',
})
setDidCopy(false)
},
)
}}
>
<Icon type="copy" size="small" />
</button>
<div
className={classNames(
didCopy && isCopyButtonVisible ? '' : 'hidden',
'absolute top-full right-0 min-w-max translate-x-2 translate-y-1 select-none rounded border border-border bg-default py-1.5 px-3 text-left md:peer-hover:block',
)}
>
{didCopy ? 'Copied!' : 'Copy example to clipboard'}
</div>
</div>
</div>
)
}
export default CopyableCodeBlock
@@ -7,7 +7,7 @@ type Props = {
}
const ModalDialogDescription: FunctionComponent<Props> = ({ children, className = '' }) => (
<AlertDialogDescription className={`overflow-y-scroll px-4 py-4 ${className}`}>{children}</AlertDialogDescription>
<AlertDialogDescription className={`overflow-y-auto px-4 py-4 ${className}`}>{children}</AlertDialogDescription>
)
export default ModalDialogDescription
@@ -0,0 +1,236 @@
import Button from '@/Components/Button/Button'
import CompoundPredicateBuilder from '@/Components/SmartViewBuilder/CompoundPredicateBuilder'
import Icon from '@/Components/Icon/Icon'
import IconPicker from '@/Components/Icon/IconPicker'
import Popover from '@/Components/Popover/Popover'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import Spinner from '@/Components/Spinner/Spinner'
import { Platform } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useEffect, useRef, useState } from 'react'
import { AddSmartViewModalController } from './AddSmartViewModalController'
import TabPanel from '../Tabs/TabPanel'
import { useTabState } from '../Tabs/useTabState'
import TabsContainer from '../Tabs/TabsContainer'
import CopyableCodeBlock from '../Shared/CopyableCodeBlock'
import { Disclosure, DisclosureButton, DisclosurePanel } from '@reach/disclosure'
import { classNames } from '@/Utils/ConcatenateClassNames'
type Props = {
controller: AddSmartViewModalController
platform: Platform
}
const ConflictedNotesExampleCode = `{
"keypath": "content.conflict_of.length",
"operator": ">",
"value": 0
}`
const ComplexCompoundExampleCode = `{
"operator": "and",
"value": [
{
"operator": "not",
"value": {
"keypath": "tags",
"operator": "includes",
"value": {
"keypath": "title",
"operator": "=",
"value": "completed"
}
}
},
{
"keypath": "tags",
"operator": "includes",
"value": {
"keypath": "title",
"operator": "=",
"value": "todo"
}
}
]
}
`
const AddSmartViewModal = ({ controller, platform }: Props) => {
const {
isSaving,
title,
setTitle,
icon,
setIcon,
closeModal,
saveCurrentSmartView,
predicateController,
customPredicateJson,
setCustomPredicateJson,
isCustomJsonValidPredicate,
setIsCustomJsonValidPredicate,
validateAndPrettifyCustomPredicate,
} = controller
const titleInputRef = useRef<HTMLInputElement>(null)
const customJsonInputRef = useRef<HTMLTextAreaElement>(null)
const [shouldShowIconPicker, setShouldShowIconPicker] = useState(false)
const iconPickerButtonRef = useRef<HTMLButtonElement>(null)
const [shouldShowJsonExamples, setShouldShowJsonExamples] = useState(false)
const toggleIconPicker = () => {
setShouldShowIconPicker((shouldShow) => !shouldShow)
}
const tabState = useTabState({
defaultTab: 'builder',
})
const save = () => {
if (!title.length) {
titleInputRef.current?.focus()
return
}
if (tabState.activeTab === 'custom' && !isCustomJsonValidPredicate) {
validateAndPrettifyCustomPredicate()
return
}
void saveCurrentSmartView()
}
const canSave = tabState.activeTab === 'builder' || isCustomJsonValidPredicate
useEffect(() => {
if (!customJsonInputRef.current) {
return
}
if (tabState.activeTab === 'custom' && isCustomJsonValidPredicate === false) {
customJsonInputRef.current.focus()
}
}, [isCustomJsonValidPredicate, tabState.activeTab])
return (
<ModalDialog>
<ModalDialogLabel closeDialog={closeModal}>Add Smart View</ModalDialogLabel>
<ModalDialogDescription>
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2.5">
<div className="text-sm font-semibold">Title:</div>
<input
className="rounded border border-border bg-default py-1 px-2"
value={title}
onChange={(event) => {
setTitle(event.target.value)
}}
ref={titleInputRef}
/>
</div>
<div className="flex items-center gap-2.5">
<div className="text-sm font-semibold">Icon:</div>
<button
className="rounded border border-border p-2"
aria-label="Change icon"
onClick={toggleIconPicker}
ref={iconPickerButtonRef}
>
<Icon type={icon || 'restore'} />
</button>
<Popover
open={shouldShowIconPicker}
anchorElement={iconPickerButtonRef.current}
togglePopover={toggleIconPicker}
align="start"
overrideZIndex="z-modal"
>
<div className="p-2">
<IconPicker
selectedValue={icon || 'restore'}
onIconChange={(value?: string | undefined) => {
setIcon(value ?? 'restore')
toggleIconPicker()
}}
platform={platform}
useIconGrid={true}
portalDropdown={false}
/>
</div>
</Popover>
</div>
<div className="flex flex-col gap-2.5">
<div className="text-sm font-semibold">Predicate:</div>
<TabsContainer
tabs={[
{
id: 'builder',
title: 'Builder',
},
{
id: 'custom',
title: 'Custom (JSON)',
},
]}
state={tabState}
>
<TabPanel state={tabState} id="builder" className="flex flex-col gap-2.5 p-4">
<CompoundPredicateBuilder controller={predicateController} />
</TabPanel>
<TabPanel state={tabState} id="custom">
<textarea
className="h-full min-h-[10rem] w-full resize-none bg-default py-1.5 px-2.5 font-mono text-sm"
value={customPredicateJson}
onChange={(event) => {
setCustomPredicateJson(event.target.value)
setIsCustomJsonValidPredicate(undefined)
}}
spellCheck={false}
ref={customJsonInputRef}
/>
{customPredicateJson && isCustomJsonValidPredicate === false && (
<div className="mt-2 border-t border-border px-2.5 py-1.5 text-sm text-danger">
Invalid JSON. Double check your entry and try again.
</div>
)}
</TabPanel>
</TabsContainer>
{tabState.activeTab === 'custom' && (
<Disclosure open={shouldShowJsonExamples} onChange={() => setShouldShowJsonExamples((show) => !show)}>
<div className="flex flex-col gap-1.5 rounded-md border-2 border-info-backdrop bg-info-backdrop py-3 px-4">
<DisclosureButton className="flex items-center justify-between focus:shadow-none focus:outline-none">
<div className="text-sm font-semibold">Examples</div>
<Icon type={shouldShowJsonExamples ? 'chevron-up' : 'chevron-down'} />
</DisclosureButton>
<DisclosurePanel className={classNames(shouldShowJsonExamples && 'flex', 'flex-col gap-2.5')}>
<div className="text-sm font-medium">1. List notes that are conflicted copies of another note:</div>
<CopyableCodeBlock code={ConflictedNotesExampleCode} />
<div className="text-sm font-medium">
2. List notes that have the tag `todo` but not the tag `completed`:
</div>
<CopyableCodeBlock code={ComplexCompoundExampleCode} />
</DisclosurePanel>
</div>
</Disclosure>
)}
</div>
</div>
</ModalDialogDescription>
<ModalDialogButtons>
<Button disabled={isSaving} onClick={closeModal} className="mr-auto">
Cancel
</Button>
<Button disabled={isSaving} onClick={save} colorStyle={canSave ? 'info' : 'default'} primary={canSave}>
{isSaving ? <Spinner className="h-4.5 w-4.5" /> : canSave ? 'Save' : 'Validate'}
</Button>
</ModalDialogButtons>
</ModalDialog>
)
}
export default observer(AddSmartViewModal)
@@ -0,0 +1,114 @@
import { WebApplication } from '@/Application/Application'
import { CompoundPredicateBuilderController } from '@/Components/SmartViewBuilder/CompoundPredicateBuilderController'
import { predicateFromJson, PredicateJsonForm } from '@standardnotes/snjs'
import { action, makeObservable, observable } from 'mobx'
export class AddSmartViewModalController {
isAddingSmartView = false
isSaving = false
title = ''
icon = 'restore'
predicateController = new CompoundPredicateBuilderController()
customPredicateJson: string | undefined = undefined
isCustomJsonValidPredicate: boolean | undefined = undefined
constructor(private application: WebApplication) {
makeObservable(this, {
isAddingSmartView: observable,
setIsAddingSmartView: action,
isSaving: observable,
setIsSaving: action,
title: observable,
setTitle: action,
icon: observable,
setIcon: action,
customPredicateJson: observable,
isCustomJsonValidPredicate: observable,
setCustomPredicateJson: action,
setIsCustomJsonValidPredicate: action,
})
}
setIsAddingSmartView = (isAddingSmartView: boolean) => {
this.isAddingSmartView = isAddingSmartView
}
setIsSaving = (isSaving: boolean) => {
this.isSaving = isSaving
}
setTitle = (title: string) => {
this.title = title
}
setIcon = (icon: string) => {
this.icon = icon
}
setCustomPredicateJson = (customPredicateJson: string) => {
this.customPredicateJson = customPredicateJson
}
setIsCustomJsonValidPredicate = (isCustomJsonValidPredicate: boolean | undefined) => {
this.isCustomJsonValidPredicate = isCustomJsonValidPredicate
}
closeModal = () => {
this.setIsAddingSmartView(false)
this.setTitle('')
this.setIcon('')
this.setIsSaving(false)
this.predicateController.resetState()
this.setCustomPredicateJson('')
this.setIsCustomJsonValidPredicate(undefined)
}
saveCurrentSmartView = async () => {
this.setIsSaving(true)
if (!this.title) {
this.setIsSaving(false)
return
}
const predicateJson =
this.customPredicateJson && this.isCustomJsonValidPredicate
? JSON.parse(this.customPredicateJson)
: this.predicateController.toJson()
const predicate = predicateFromJson(predicateJson as PredicateJsonForm)
await this.application.items.createSmartView(this.title, predicate, this.icon)
this.setIsSaving(false)
this.closeModal()
}
validateAndPrettifyCustomPredicate = () => {
if (!this.customPredicateJson) {
this.setIsCustomJsonValidPredicate(false)
return
}
try {
const parsedPredicate: PredicateJsonForm = JSON.parse(this.customPredicateJson)
const predicate = predicateFromJson(parsedPredicate)
if (predicate) {
this.setCustomPredicateJson(JSON.stringify(parsedPredicate, null, 2))
this.setIsCustomJsonValidPredicate(true)
} else {
this.setIsCustomJsonValidPredicate(false)
}
} catch (error) {
this.setIsCustomJsonValidPredicate(false)
return
}
}
}
@@ -0,0 +1,127 @@
import { AllNonCompoundPredicateOperators, PredicateCompoundOperator, PredicateOperator } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import Button from '../Button/Button'
import Icon from '../Icon/Icon'
import { CompoundPredicateBuilderController } from './CompoundPredicateBuilderController'
import { PredicateKeypath, PredicateKeypathLabels, PredicateKeypathTypes } from './PredicateKeypaths'
import PredicateValue from './PredicateValue'
type Props = {
controller: CompoundPredicateBuilderController
}
const CompoundPredicateBuilder = ({ controller }: Props) => {
const { operator, setOperator, predicates, setPredicate, changePredicateKeypath, addPredicate, removePredicate } =
controller
return (
<>
<div className="flex flex-col gap-1">
<label className="flex items-center gap-2">
<input
type="radio"
name="predicate"
value="and"
checked={operator === 'and'}
onChange={(event) => {
setOperator(event.target.value as PredicateCompoundOperator)
}}
/>
Should match ALL conditions
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="predicate"
value="or"
checked={operator === 'or'}
onChange={(event) => {
setOperator(event.target.value as PredicateCompoundOperator)
}}
/>
Should match ANY conditions
</label>
</div>
{predicates.map((predicate, index) => (
<div className="flex flex-col gap-2.5" key={index}>
<div className="flex w-full flex-col gap-2 md:flex-row md:items-center">
{index !== 0 && <div className="mr-2 text-sm font-semibold">{operator === 'and' ? 'AND' : 'OR'}</div>}
<select
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
value={predicate.keypath}
onChange={(event) => {
changePredicateKeypath(index, event.target.value as PredicateKeypath)
}}
>
{Object.entries(PredicateKeypathLabels).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
<select
className="rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
value={predicate.operator}
onChange={(event) => {
setPredicate(index, { operator: event.target.value as PredicateOperator })
}}
>
{AllNonCompoundPredicateOperators.map((operator) => (
<option key={operator} value={operator}>
{operator}
</option>
))}
</select>
{predicate.keypath && (
<PredicateValue
keypath={predicate.keypath as PredicateKeypath}
value={predicate.value.toString()}
setValue={(value: string) => {
setPredicate(index, { value })
}}
/>
)}
{index !== 0 && (
<button
className="rounded border border-border p-1 text-danger"
aria-label="Remove condition"
onClick={() => {
removePredicate(index)
}}
>
<Icon type="trash" />
</button>
)}
</div>
{index === predicates.length - 1 && (
<Button
className="flex items-center gap-2"
onClick={() => {
addPredicate()
}}
>
Add another condition
</Button>
)}
</div>
))}
{predicates.some((predicate) => PredicateKeypathTypes[predicate.keypath as PredicateKeypath] === 'date') && (
<div className="flex flex-col gap-2 rounded-md border-2 border-info-backdrop bg-info-backdrop py-3 px-4 [&_code]:rounded [&_code]:bg-default [&_code]:px-1.5 [&_code]:py-1">
<div className="text-sm font-semibold">Date Examples:</div>
<ul className="space-y-2 pl-4">
<li>
To get all the items modified within the last 7 days, you can use <code>User Modified Date</code>{' '}
<code>&gt;</code> <code>7.days.ago</code>
</li>
<li>
To get all the items created before June 2022, you can use <code>Created At</code> <code>&lt;</code>{' '}
<code>06/01/2022</code>
</li>
</ul>
</div>
)}
</>
)
}
export default observer(CompoundPredicateBuilder)
@@ -0,0 +1,92 @@
import { PlainEditorType } from '@/Utils/DropdownItemsForEditors'
import { NoteType, PredicateCompoundOperator, PredicateJsonForm } from '@standardnotes/snjs'
import { makeObservable, observable, action } from 'mobx'
import { PredicateKeypath, PredicateKeypathTypes } from './PredicateKeypaths'
const getEmptyPredicate = (): PredicateJsonForm => {
return {
keypath: 'title',
operator: '!=',
value: '',
}
}
export class CompoundPredicateBuilderController {
operator: PredicateCompoundOperator = 'and'
predicates: PredicateJsonForm[] = [getEmptyPredicate()]
constructor() {
makeObservable(this, {
operator: observable,
setOperator: action,
predicates: observable,
setPredicate: action,
addPredicate: action,
removePredicate: action,
})
}
setOperator = (operator: PredicateCompoundOperator) => {
this.operator = operator
}
setPredicate = (index: number, predicate: Partial<PredicateJsonForm>) => {
const predicateAtIndex = this.predicates[index]
this.predicates[index] = {
...predicateAtIndex,
...predicate,
}
}
changePredicateKeypath = (index: number, keypath: string) => {
const currentKeyPath = this.predicates[index].keypath as PredicateKeypath
const currentKeyPathType = PredicateKeypathTypes[currentKeyPath]
const newKeyPathType = PredicateKeypathTypes[keypath as PredicateKeypath]
if (currentKeyPathType !== newKeyPathType) {
switch (newKeyPathType) {
case 'string':
this.setPredicate(index, { value: '' })
break
case 'boolean':
this.setPredicate(index, { value: true })
break
case 'number':
this.setPredicate(index, { value: 0 })
break
case 'noteType':
this.setPredicate(index, { value: Object.values(NoteType)[0] })
break
case 'editorIdentifier':
this.setPredicate(index, { value: PlainEditorType })
break
case 'date':
this.setPredicate(index, { value: '1.days.ago' })
break
}
}
this.setPredicate(index, { keypath })
}
addPredicate = () => {
this.predicates.push(getEmptyPredicate())
}
removePredicate = (index: number) => {
this.predicates.splice(index, 1)
}
toJson(): PredicateJsonForm {
return {
operator: this.operator,
value: this.predicates,
}
}
resetState() {
this.operator = 'and'
this.predicates = [getEmptyPredicate()]
}
}
@@ -0,0 +1,66 @@
export type PredicateKeypath =
| 'title'
| 'title.length'
| 'text'
| 'text.length'
| 'noteType'
| 'authorizedForListed'
| 'editorIdentifier'
| 'userModifiedDate'
| 'serverUpdatedAt'
| 'created_at'
| 'conflict_of'
| 'protected'
| 'trashed'
| 'pinned'
| 'archived'
| 'locked'
| 'starred'
| 'hidePreview'
| 'spellcheck'
export const PredicateKeypathLabels: { [k in PredicateKeypath]: string } = {
title: 'Title',
'title.length': 'Title Length',
text: 'Text',
'text.length': 'Text Length',
noteType: 'Note Type',
authorizedForListed: 'Authorized For Listed',
editorIdentifier: 'Editor Identifier',
userModifiedDate: 'User Modified Date',
serverUpdatedAt: 'Server Updated At',
created_at: 'Created At',
conflict_of: 'Conflict Of',
protected: 'Protected',
trashed: 'Trashed',
pinned: 'Pinned',
archived: 'Archived',
locked: 'Locked',
starred: 'Starred',
hidePreview: 'Hide Preview',
spellcheck: 'Spellcheck',
} as const
export const PredicateKeypathTypes: {
[k in PredicateKeypath]: 'string' | 'noteType' | 'editorIdentifier' | 'number' | 'boolean' | 'date'
} = {
title: 'string',
'title.length': 'number',
text: 'string',
'text.length': 'number',
noteType: 'noteType',
authorizedForListed: 'boolean',
editorIdentifier: 'editorIdentifier',
userModifiedDate: 'date',
serverUpdatedAt: 'date',
created_at: 'date',
conflict_of: 'string',
protected: 'boolean',
trashed: 'boolean',
pinned: 'boolean',
archived: 'boolean',
locked: 'boolean',
starred: 'boolean',
hidePreview: 'boolean',
spellcheck: 'boolean',
} as const
@@ -0,0 +1,76 @@
import { getDropdownItemsForAllEditors } from '@/Utils/DropdownItemsForEditors'
import { NoteType } from '@standardnotes/snjs'
import { useApplication } from '../ApplicationView/ApplicationProvider'
import { PredicateKeypath, PredicateKeypathTypes } from './PredicateKeypaths'
type Props = {
keypath: PredicateKeypath
value: string
setValue: (value: string) => void
}
const PredicateValue = ({ keypath, value, setValue }: Props) => {
const application = useApplication()
const type = PredicateKeypathTypes[keypath]
const editorItems = getDropdownItemsForAllEditors(application)
return type === 'noteType' ? (
<select
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
value={value}
onChange={(event) => {
setValue(event.target.value)
}}
>
{Object.entries(NoteType).map(([key, value]) => (
<option key={key} value={value}>
{key}
</option>
))}
</select>
) : type === 'editorIdentifier' ? (
<select
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
value={value}
onChange={(event) => {
setValue(event.target.value)
}}
>
{editorItems.map((editor) => (
<option key={editor.value} value={editor.value}>
{editor.label}
</option>
))}
</select>
) : type === 'string' || type === 'date' ? (
<input
className="flex-grow rounded border border-border bg-default py-1.5 px-2"
value={value}
onChange={(event) => {
setValue(event.target.value)
}}
/>
) : type === 'boolean' ? (
<select
className="flex-grow rounded border border-border bg-default py-1.5 px-2 focus:outline focus:outline-1 focus:outline-info"
value={value}
onChange={(event) => {
setValue(event.target.value)
}}
>
<option value="true">True</option>
<option value="false">False</option>
</select>
) : type === 'number' ? (
<input
type="number"
className="flex-grow rounded border border-border bg-default py-1.5 px-2"
value={value}
onChange={(event) => {
setValue(event.target.value)
}}
/>
) : null
}
export default PredicateValue
@@ -0,0 +1,35 @@
import { classNames } from '@/Utils/ConcatenateClassNames'
import { ComponentPropsWithoutRef } from 'react'
import { useTabStateContext } from './useTabState'
type Props = { id: string } & ComponentPropsWithoutRef<'button'>
const Tab = ({ id, className, children, ...props }: Props) => {
const { state } = useTabStateContext()
const { activeTab, setActiveTab } = state
const isActive = activeTab === id
return (
<button
role="tab"
id={`tab-control-${id}`}
onClick={() => {
setActiveTab(id)
}}
aria-selected={isActive}
aria-controls={`tab-panel-${id}`}
className={classNames(
'relative cursor-pointer border-0 bg-default px-3 py-2.5 text-sm focus:shadow-inner',
isActive ? 'font-medium text-info' : 'text-text',
isActive && 'after:absolute after:bottom-0 after:left-0 after:h-[2px] after:w-full after:bg-info',
className,
)}
{...props}
>
{children}
</button>
)
}
export default Tab
@@ -0,0 +1,25 @@
import { ComponentPropsWithoutRef, useMemo } from 'react'
import { TabStateContext, TabState } from './useTabState'
type Props = {
state: TabState
} & ComponentPropsWithoutRef<'div'>
const TabList = ({ state, children, ...props }: Props) => {
const providerValue = useMemo(
() => ({
state,
}),
[state],
)
return (
<TabStateContext.Provider value={providerValue}>
<div role="tablist" {...props}>
{children}
</div>
</TabStateContext.Provider>
)
}
export default TabList
@@ -0,0 +1,22 @@
import { ComponentPropsWithoutRef } from 'react'
import { TabState } from './useTabState'
type Props = { state: TabState; id: string } & ComponentPropsWithoutRef<'div'>
const TabPanel = ({ state, id, children, ...props }: Props) => {
const { activeTab } = state
const isActive = activeTab === id
if (!isActive) {
return null
}
return (
<div role="tabpanel" id={`tab-panel-${id}`} aria-labelledby={`tab-control-${id}`} {...props}>
{children}
</div>
)
}
export default TabPanel
@@ -0,0 +1,29 @@
import Tab from './Tab'
import TabList from './TabList'
import { TabState } from './useTabState'
type Props = {
tabs: {
id: string
title: string
}[]
state: TabState
children: React.ReactNode
}
const TabsContainer = ({ tabs, state, children }: Props) => {
return (
<div className="overflow-hidden rounded-md border border-border">
<TabList state={state} className="border-b border-border">
{tabs.map(({ id, title }) => (
<Tab key={id} id={id} className="first:rounded-tl-md">
{title}
</Tab>
))}
</TabList>
{children}
</div>
)
}
export default TabsContainer
@@ -0,0 +1,29 @@
import { createContext, useContext, useState } from 'react'
export const useTabState = ({ defaultTab }: { defaultTab: string }) => {
const [activeTab, setActiveTab] = useState(defaultTab)
return { activeTab, setActiveTab }
}
export type TabState = ReturnType<typeof useTabState>
type TabContextValue = {
state: TabState
}
export const TabStateContext = createContext<TabContextValue | undefined>(undefined)
export const useTabStateContext = () => {
const context = useContext(TabStateContext)
if (context === undefined) {
throw new Error('useTabStateContext must be used within a <TabList/>')
}
if (context.state === undefined) {
throw new Error('Tab state must be provided to the parent <TabList/>')
}
return context
}
@@ -57,7 +57,7 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
return (
<div
className={classNames(
'fixed bottom-0 flex min-h-[50px] w-full w-full items-center border-t border-border bg-contrast',
'fixed bottom-0 flex min-h-[50px] w-full items-center border-t border-border bg-contrast',
'px-3.5 pb-safe-bottom pt-2.5 md:hidden',
)}
>
@@ -135,14 +135,7 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
'md:hover:[overflow-y:_overlay]',
)}
>
<div className={'section-title-bar'}>
<div className="section-title-bar-header">
<div className="title text-base md:text-sm">
<span className="font-bold">Views</span>
</div>
</div>
</div>
<SmartViewsSection viewControllerManager={viewControllerManager} />
<SmartViewsSection application={application} viewControllerManager={viewControllerManager} />
<TagsSection viewControllerManager={viewControllerManager} />
</div>
{NavigationFooter}
@@ -1,13 +1,15 @@
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { SmartView } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import SmartViewsListItem from './SmartViewsListItem'
type Props = {
viewControllerManager: ViewControllerManager
setEditingSmartView: (smartView: SmartView) => void
}
const SmartViewsList: FunctionComponent<Props> = ({ viewControllerManager }: Props) => {
const SmartViewsList: FunctionComponent<Props> = ({ viewControllerManager, setEditingSmartView }: Props) => {
const allViews = viewControllerManager.navigationController.smartViews
return (
@@ -19,6 +21,7 @@ const SmartViewsList: FunctionComponent<Props> = ({ viewControllerManager }: Pro
view={view}
tagsState={viewControllerManager.navigationController}
features={viewControllerManager.featuresController}
setEditingSmartView={setEditingSmartView}
/>
)
})}
@@ -22,6 +22,7 @@ type Props = {
view: SmartView
tagsState: NavigationController
features: FeaturesController
setEditingSmartView: (smartView: SmartView) => void
}
const PADDING_BASE_PX = 14
@@ -35,7 +36,7 @@ const getIconClass = (view: SmartView, isSelected: boolean): string => {
return mapping[view.uuid as SystemViewId] || (isSelected ? 'text-info' : 'text-neutral')
}
const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState, setEditingSmartView }) => {
const { toggleAppPane } = useResponsiveAppPane()
const [title, setTitle] = useState(view.title || '')
@@ -85,13 +86,9 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
}
}, [inputRef, isEditing])
const onClickRename = useCallback(() => {
tagsState.setEditingTag(view)
}, [tagsState, view])
const onClickSave = useCallback(() => {
inputRef.current?.blur()
}, [inputRef])
const onClickEdit = useCallback(() => {
setEditingSmartView(view)
}, [setEditingSmartView, view])
const onClickDelete = useCallback(() => {
tagsState.remove(view, true).catch(console.error)
@@ -107,6 +104,11 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
className={classNames('tag px-3.5', isSelected && 'selected', isFaded && 'opacity-50')}
onClick={selectCurrentTag}
onContextMenu={(event) => {
event.preventDefault()
event.stopPropagation()
onClickEdit()
}}
style={{
paddingLeft: `${level * PADDING_PER_LEVEL_PX + PADDING_BASE_PX}px`,
}}
@@ -147,16 +149,9 @@ const SmartViewsListItem: FunctionComponent<Props> = ({ view, tagsState }) => {
{isSelected && (
<div className="menu">
{!isEditing && (
<a className="item" onClick={onClickRename}>
Rename
</a>
)}
{isEditing && (
<a className="item" onClick={onClickSave}>
Save
</a>
)}
<a className="item" onClick={onClickEdit}>
Edit
</a>
<a className="item" onClick={onClickDelete}>
Delete
</a>
@@ -1,16 +1,66 @@
import { WebApplication } from '@/Application/Application'
import { SMART_TAGS_FEATURE_NAME } from '@/Constants/Constants'
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { usePremiumModal } from '@/Hooks/usePremiumModal'
import { SmartView } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import { FunctionComponent, useCallback, useMemo, useState } from 'react'
import IconButton from '../Button/IconButton'
import EditSmartViewModal from '../Preferences/Panes/General/SmartViews/EditSmartViewModal'
import AddSmartViewModal from '../SmartViewBuilder/AddSmartViewModal'
import { AddSmartViewModalController } from '../SmartViewBuilder/AddSmartViewModalController'
import SmartViewsList from './SmartViewsList'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
}
const SmartViewsSection: FunctionComponent<Props> = ({ viewControllerManager }) => {
const SmartViewsSection: FunctionComponent<Props> = ({ application, viewControllerManager }) => {
const premiumModal = usePremiumModal()
const addSmartViewModalController = useMemo(() => new AddSmartViewModalController(application), [application])
const [editingSmartView, setEditingSmartView] = useState<SmartView | undefined>(undefined)
const createNewSmartView = useCallback(() => {
if (!viewControllerManager.featuresController.hasSmartViews) {
premiumModal.activate(SMART_TAGS_FEATURE_NAME)
return
}
addSmartViewModalController.setIsAddingSmartView(true)
}, [addSmartViewModalController, premiumModal, viewControllerManager.featuresController.hasSmartViews])
return (
<section>
<SmartViewsList viewControllerManager={viewControllerManager} />
<div className={'section-title-bar'}>
<div className="section-title-bar-header">
<div className="title text-base md:text-sm">
<span className="font-bold">Views</span>
</div>
<IconButton
focusable={true}
icon="add"
title="Create a new smart view"
className="p-0 text-neutral"
onClick={createNewSmartView}
/>
</div>
</div>
<SmartViewsList viewControllerManager={viewControllerManager} setEditingSmartView={setEditingSmartView} />
{!!editingSmartView && (
<EditSmartViewModal
application={application}
navigationController={viewControllerManager.navigationController}
view={editingSmartView}
closeDialog={() => {
setEditingSmartView(undefined)
}}
/>
)}
{addSmartViewModalController.isAddingSmartView && (
<AddSmartViewModal controller={addSmartViewModalController} platform={application.platform} />
)}
</section>
)
}
@@ -78,6 +78,8 @@ const TagContextMenu = ({ navigationController, isEntitledToFolders, selectedTag
selectedValue={selectedTag.iconString}
platform={application.platform}
className={'px-3 py-1.5'}
useIconGrid={true}
iconGridClassName="max-h-30"
/>
<HorizontalSeparator classes="my-2" />
<MenuItem type={MenuItemType.IconButton} className={'justify-between py-1.5'} onClick={onClickStar}>
@@ -0,0 +1,3 @@
export enum FeatureName {
Files = 'Encrypted File Storage',
}
@@ -1,3 +1,4 @@
import { FeatureName } from './FeatureName'
import { WebApplication } from '@/Application/Application'
import { PremiumFeatureModalType } from '@/Components/PremiumFeaturesModal/PremiumFeatureModalType'
import { destroyAllObjectProperties } from '@/Utils'
@@ -15,7 +16,7 @@ import { CrossControllerEvent } from './CrossControllerEvent'
export class FeaturesController extends AbstractViewController {
hasFolders: boolean
hasSmartViews: boolean
hasFiles: boolean
entitledToFiles: boolean
premiumAlertFeatureName: string | undefined
premiumAlertType: PremiumFeatureModalType | undefined = undefined
@@ -25,7 +26,7 @@ export class FeaturesController extends AbstractViewController {
;(this.closePremiumAlert as unknown) = undefined
;(this.hasFolders as unknown) = undefined
;(this.hasSmartViews as unknown) = undefined
;(this.hasFiles as unknown) = undefined
;(this.entitledToFiles as unknown) = undefined
;(this.premiumAlertFeatureName as unknown) = undefined
;(this.premiumAlertType as unknown) = undefined
@@ -37,7 +38,7 @@ export class FeaturesController extends AbstractViewController {
this.hasFolders = this.isEntitledToFolders()
this.hasSmartViews = this.isEntitledToSmartViews()
this.hasFiles = this.isEntitledToFiles()
this.entitledToFiles = this.isEntitledToFiles()
this.premiumAlertFeatureName = undefined
eventBus.addEventHandler(this, CrossControllerEvent.DisplayPremiumModal)
@@ -45,7 +46,7 @@ export class FeaturesController extends AbstractViewController {
makeObservable(this, {
hasFolders: observable,
hasSmartViews: observable,
hasFiles: observable,
entitledToFiles: observable,
premiumAlertType: observable,
premiumAlertFeatureName: observable,
showPremiumAlert: action,
@@ -67,7 +68,7 @@ export class FeaturesController extends AbstractViewController {
runInAction(() => {
this.hasFolders = this.isEntitledToFolders()
this.hasSmartViews = this.isEntitledToSmartViews()
this.hasFiles = this.isEntitledToFiles()
this.entitledToFiles = this.isEntitledToFiles()
})
}
}),
@@ -81,7 +82,7 @@ export class FeaturesController extends AbstractViewController {
}
}
public async showPremiumAlert(featureName: string): Promise<void> {
public async showPremiumAlert(featureName: FeatureName | string): Promise<void> {
this.premiumAlertFeatureName = featureName
this.premiumAlertType = PremiumFeatureModalType.UpgradePrompt
+1
View File
@@ -8,6 +8,7 @@ module.exports = {
extend: {
spacing: {
4.5: '1.125rem',
5.5: '1.325rem',
8.5: '2.125rem',
13: '3.25rem',
15: '3.75rem',