Compare commits

...
Author SHA1 Message Date
StandardNotes CI ecd8d56171 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].7
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected].2
2022-09-29 10:01:20 +00:00
Karol Sójko 33226000f5 fix(snjs): increase wait time in retrieving settings test for processing replaceable settings 2022-09-29 11:30:36 +02:00
Karol Sójko c841ac99d2 fix(snjs): instructions on running e2e locally 2022-09-29 11:07:12 +02:00
StandardNotes CI b0e708186c chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
2022-09-29 08:54:37 +00:00
Aman Harwara 20e420820d fix: ipad web view ui improvements (#1664) 2022-09-29 13:47:00 +05:30
StandardNotes CI c3d6a91730 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].5
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2022-09-29 07:45:24 +00:00
Aman Harwara 0ed692ff9b feat: add panel settings section in quick settings (#1669) 2022-09-29 12:46:06 +05:30
StandardNotes CI c74fa272fb chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].4
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].4
2022-09-28 16:34:58 +00:00
Aman Harwara 3f9d3ceffa fix: improve biometrics input on mobile webview (#1658) 2022-09-28 21:26:20 +05:30
StandardNotes CI f6d0aa0f0a chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected].3
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].3
2022-09-28 15:16:53 +00:00
Aman Harwara eafa65e934 fix: grey flash on textarea focus on safari/webkit (#1661) 2022-09-28 20:11:03 +05:30
Mo a83003ee69 feat(crypto): pkc algos for key generation, encrypt, and decrypt (#1663) 2022-09-28 09:15:34 -05:00
StandardNotes CI 554f59a3fe chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
2022-09-28 13:42:14 +00:00
Aman Harwara c0af596f2d fix: sticky header behavior on iOS (#1662) 2022-09-28 18:36:42 +05:30
StandardNotes CI 6e10911d02 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
2022-09-28 09:41:43 +00:00
Aman Harwara 9502a7fa32 fix: challenge modal 2022-09-28 14:30:31 +05:30
Aman Harwara 727d78cfe2 fix: webapp blanking because of mobileDevice check 2022-09-28 14:24:05 +05:30
StandardNotes CI 44bb4b7ca3 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2022-09-28 07:19:59 +00:00
Aman Harwara 981d8a7497 feat: handle android back button on android (#1656) 2022-09-28 12:12:55 +05:30
StandardNotes CI 04245dfeeb chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2022-09-27 14:51:15 +00:00
Vardan HakobyanandMo 5db47e83e8 feat: delete account (#1653)
* feat: delete account

* fix: copy adjustments

Co-authored-by: Mo
2022-09-27 18:13:54 +04:00
StandardNotes CI c08602b7b8 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2022-09-27 13:28:41 +00:00
Aman Harwara ec52e5684d feat: upgrade cta (#1655) 2022-09-27 18:29:50 +05:30
StandardNotes CI f3f7f9d362 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].3
2022-09-27 12:52:54 +00:00
Mo b9a07d8b5a fix(web): incorrect native access 2022-09-27 07:23:21 -05:00
StandardNotes CI 31213bf268 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
2022-09-27 11:20:27 +00:00
Aman Harwara 70e57e32b3 fix: don't hide note tags container on desktop 2022-09-27 16:19:06 +05:30
StandardNotes CI 720215eea7 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].5
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
2022-09-27 07:23:11 +00:00
Aman Harwara 6d5ebdeaa1 fix: export/sharing notes on mobile webview (#1644) 2022-09-27 12:24:44 +05:30
StandardNotes CI c99693876f chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2022-09-26 16:21:50 +00:00
Aman Harwara f8f6a5212b feat: sticky header on mobile if plaintext note is scrolled (#1645) 2022-09-26 21:15:11 +05:30
StandardNotes CI 58cd919267 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
2022-09-26 12:42:43 +00:00
Karol Sójko 507823dc4f fix(web): check for proper feature in the subscription sharing section 2022-09-26 14:12:01 +02:00
StandardNotes CI 6ea1def0cf chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2022-09-25 22:06:15 +00:00
Aman Harwara fe2ce9f1e8 feat: note tags button for mobile (#1641) 2022-09-26 02:58:55 +05:30
StandardNotes CI 4985787c5d chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
2022-09-25 16:08:44 +00:00
Aman Harwara 8614a638a4 fix(mobile): hide drawer on new mobile experience (#1642) 2022-09-25 21:04:04 +05:30
StandardNotes CI 15957c71cb chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].9
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].3
2022-09-24 21:45:51 +00:00
Aman Harwara 59e814bcec fix: improve new note title format (#1640) 2022-09-24 16:18:06 -05:00
StandardNotes CI 4cf9ebf52a chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].8
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
2022-09-24 19:32:22 +00:00
Aman Harwara a3611c802f fix: move prefs button next to account menu button in footer (#1639) 2022-09-25 00:33:30 +05:30
StandardNotes CI 9be315730d chore(release): publish
- @standardnotes/[email protected].7
 - @standardnotes/[email protected]
2022-09-24 17:28:41 +00:00
Mo 789a78bda3 fix(mobile): accurate version number 2022-09-24 11:58:29 -05:00
StandardNotes CI 73e170dedd chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
2022-09-24 16:32:37 +00:00
Aman Harwara e4b1b64119 fix: responsiveness when resizing window on web/desktop (#1637) 2022-09-24 21:24:10 +05:30
102 changed files with 2205 additions and 594 deletions
+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.8.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/api
## [1.8.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/api
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/api",
"version": "1.8.3",
"version": "1.8.4",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+76
View File
@@ -3,6 +3,82 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.23.173](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-29)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.172](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-29)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.171](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-29)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.170](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.169](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.168](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.167](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.166](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.165](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.164](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.163](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.162](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.161](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.160](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-26)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.159](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-26)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.158](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-25)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.157](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.156](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.155](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.154](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/desktop
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@standardnotes/desktop",
"main": "./app/dist/index.js",
"version": "3.23.154",
"version": "3.23.173",
"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.15.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/encryption
## [1.15.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/encryption
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/encryption",
"version": "1.15.5",
"version": "1.15.6",
"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.23.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/filepicker
## [1.23.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/filepicker
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/filepicker",
"version": "1.23.5",
"version": "1.23.6",
"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.10.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/files
## [1.10.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/files
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/files",
"version": "1.10.5",
"version": "1.10.6",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+92
View File
@@ -3,6 +3,98 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.37.7](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-29)
**Note:** Version bump only for package @standardnotes/mobile
## [3.37.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-29)
**Note:** Version bump only for package @standardnotes/mobile
## [3.37.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-29)
**Note:** Version bump only for package @standardnotes/mobile
## [3.37.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/mobile
## [3.37.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/mobile
## [3.37.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/mobile
## [3.37.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/mobile
# [3.37.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
### Features
* handle android back button on android ([#1656](https://github.com/standardnotes/app/issues/1656)) ([981d8a7](https://github.com/standardnotes/app/commit/981d8a7497ea2600e1535ad9170cb5559550ca4d))
## [3.36.18](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/mobile
## [3.36.17](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/mobile
## [3.36.16](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/mobile
## [3.36.15](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/mobile
## [3.36.14](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
### Bug Fixes
* export/sharing notes on mobile webview ([#1644](https://github.com/standardnotes/app/issues/1644)) ([6d5ebde](https://github.com/standardnotes/app/commit/6d5ebdeaa1777f31878b6e7d4dfc33fe83abc9fc))
## [3.36.13](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-26)
**Note:** Version bump only for package @standardnotes/mobile
## [3.36.12](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-26)
**Note:** Version bump only for package @standardnotes/mobile
## [3.36.11](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-25)
**Note:** Version bump only for package @standardnotes/mobile
## [3.36.10](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-25)
### Bug Fixes
* **mobile:** hide drawer on new mobile experience ([#1642](https://github.com/standardnotes/app/issues/1642)) ([8614a63](https://github.com/standardnotes/app/commit/8614a638a4bfc8fdf97020bf1fc147c62998cf3b))
## [3.36.9](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/mobile
## [3.36.8](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/mobile
## [3.36.7](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
### Bug Fixes
* **mobile:** accurate version number ([789a78b](https://github.com/standardnotes/app/commit/789a78bda33f12d214c12a63ae27d2ba0f39feab))
## [3.36.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/mobile
## [3.36.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/mobile
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/mobile",
"version": "3.36.5",
"version": "3.37.7",
"author": "Standard Notes.",
"private": true,
"license": "AGPL-3.0-or-later",
@@ -69,7 +69,7 @@
"react-native-screens": "3.13.1",
"react-native-search-bar": "standardnotes/react-native-search-bar#7d2139daf9b7663b570403f21f520deceba9bb09",
"react-native-search-box": "standardnotes/react-native-search-box#8c46369912cba78dca718588aca9c16926953ff7",
"react-native-share": "^7.3.7",
"react-native-share": "^7.9.0",
"react-native-sodium-jsi": "1.2.0",
"react-native-static-server": "standardnotes/react-native-static-server#28ef0175dbee3db9aadfab57498497067556a836",
"react-native-store-review": "^0.2.0",
@@ -0,0 +1,21 @@
import { AbstractService, InternalEventBus, ReactNativeToWebEvent } from '@standardnotes/snjs'
import { BackHandler, NativeEventSubscription } from 'react-native'
export class AndroidBackHandlerService extends AbstractService<ReactNativeToWebEvent> {
private removeListener: NativeEventSubscription
constructor() {
const internalEventBus = new InternalEventBus()
super(internalEventBus)
this.removeListener = BackHandler.addEventListener('hardwareBackPress', () => {
void this.notifyEvent(ReactNativeToWebEvent.AndroidBackButtonPressed)
return true
})
}
deinit() {
this.removeListener.remove()
}
}
+3 -7
View File
@@ -121,7 +121,9 @@ export const AppStackComponent = (props: ModalStackNavigationProp<'AppStack'>) =
[application],
)
if (IsMobileWeb) {
const shouldOpenWebApp = application?.getValue(AlwaysOpenWebAppOnLaunchKey, StorageValueModes.Nonwrapped) as boolean
if (IsMobileWeb || shouldOpenWebApp) {
return (
<AppStack.Navigator
screenOptions={() => ({
@@ -134,12 +136,6 @@ export const AppStackComponent = (props: ModalStackNavigationProp<'AppStack'>) =
)
}
if (!application) {
return null
}
const shouldOpenWebApp = application.getValue(AlwaysOpenWebAppOnLaunchKey, StorageValueModes.Nonwrapped) as boolean
return (
<DrawerLayout
ref={drawerRef}
+120 -2
View File
@@ -1,4 +1,5 @@
import AsyncStorage from '@react-native-community/async-storage'
import { AndroidBackHandlerService } from '@Root/AndroidBackHandlerService'
import SNReactNative from '@standardnotes/react-native-utils'
import {
ApplicationIdentifier,
@@ -7,16 +8,27 @@ import {
LegacyRawKeychainValue,
MobileDeviceInterface,
NamespacedRootKeyInKeychain,
Platform as SNPlatform,
RawKeychainValue,
removeFromArray,
TransferPayload,
} from '@standardnotes/snjs'
import { Alert, Linking, Platform, StatusBar } from 'react-native'
import { Alert, Linking, PermissionsAndroid, Platform, StatusBar } from 'react-native'
import FingerprintScanner from 'react-native-fingerprint-scanner'
import FlagSecure from 'react-native-flag-secure-android'
import {
CachesDirectoryPath,
DocumentDirectoryPath,
DownloadDirectoryPath,
exists,
unlink,
writeFile,
} from 'react-native-fs'
import { hide, show } from 'react-native-privacy-snapshot'
import Share from 'react-native-share'
import { AppStateObserverService } from './../AppStateObserverService'
import Keychain from './Keychain'
import { SNReactNativeCrypto } from './ReactNativeCrypto'
import { IsMobileWeb } from './Utils'
export type BiometricsType = 'Fingerprint' | 'Face ID' | 'Biometrics' | 'Touch ID'
@@ -64,14 +76,23 @@ const showLoadFailForItemIds = (failedItemIds: string[]) => {
export class MobileDevice implements MobileDeviceInterface {
environment: Environment.Mobile = Environment.Mobile
platform: SNPlatform.Ios | SNPlatform.Android = Platform.OS === 'ios' ? SNPlatform.Ios : SNPlatform.Android
private eventObservers: MobileDeviceEventHandler[] = []
public isDarkMode = false
private crypto: SNReactNativeCrypto
constructor(private stateObserverService?: AppStateObserverService) {}
constructor(
private stateObserverService?: AppStateObserverService,
private androidBackHandlerService?: AndroidBackHandlerService,
) {
this.crypto = new SNReactNativeCrypto()
}
deinit() {
this.stateObserverService?.deinit()
;(this.stateObserverService as unknown) = undefined
this.androidBackHandlerService?.deinit()
;(this.androidBackHandlerService as unknown) = undefined
}
consoleLog(...args: any[]): void {
@@ -455,4 +476,101 @@ export class MobileDevice implements MobileDeviceInterface {
isDeviceDestroyed() {
return false
}
async deleteFileAtPathIfExists(path: string) {
if (await exists(path)) {
await unlink(path)
}
}
async shareBase64AsFile(base64: string, filename: string) {
let downloadedTempFilePath: string | undefined
try {
downloadedTempFilePath = await this.downloadBase64AsFile(base64, filename, true)
if (!downloadedTempFilePath) {
return
}
await Share.open({
url: `file://${downloadedTempFilePath}`,
failOnCancel: false,
})
} catch (error) {
this.consoleLog(`${error}`)
} finally {
if (downloadedTempFilePath) {
void this.deleteFileAtPathIfExists(downloadedTempFilePath)
}
}
}
getFileDestinationPath(filename: string, saveInTempLocation: boolean): string {
let directory = DocumentDirectoryPath
if (Platform.OS === 'android') {
directory = saveInTempLocation ? CachesDirectoryPath : DownloadDirectoryPath
}
return `${directory}/${filename}`
}
async hasStoragePermissionOnAndroid(): Promise<boolean> {
if (Platform.OS !== 'android') {
return true
}
const grantedStatus = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE)
if (grantedStatus === PermissionsAndroid.RESULTS.GRANTED) {
return true
}
Alert.alert(
'Storage permissions are required in order to download files. Please accept the permissions prompt and try again.',
)
return false
}
async downloadBase64AsFile(
base64: string,
filename: string,
saveInTempLocation = false,
): Promise<string | undefined> {
const isGrantedStoragePermissionOnAndroid = await this.hasStoragePermissionOnAndroid()
if (!isGrantedStoragePermissionOnAndroid) {
return
}
try {
const path = this.getFileDestinationPath(filename, saveInTempLocation)
void this.deleteFileAtPathIfExists(path)
const decodedContents = this.crypto.base64Decode(base64.replace(/data.*base64,/, ''))
await writeFile(path, decodedContents)
return path
} catch (error) {
this.consoleLog(`${error}`)
}
}
confirmAndExit() {
Alert.alert(
'Close app',
'Do you want to close the app?',
[
{
text: 'Cancel',
style: 'cancel',
// eslint-disable-next-line @typescript-eslint/no-empty-function
onPress: async () => {},
},
{
text: 'Close',
style: 'destructive',
onPress: async () => {
SNReactNative.exitApp()
},
},
],
{
cancelable: true,
},
)
}
}
+20 -5
View File
@@ -3,7 +3,10 @@ import { IsDev } from '@Lib/Utils'
import { ReactNativeToWebEvent } from '@standardnotes/snjs'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Keyboard, Platform } from 'react-native'
import VersionInfo from 'react-native-version-info'
import { WebView, WebViewMessageEvent } from 'react-native-webview'
import pjson from '../package.json'
import { AndroidBackHandlerService } from './AndroidBackHandlerService'
import { AppStateObserverService } from './AppStateObserverService'
const LoggingEnabled = IsDev
@@ -22,13 +25,23 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
const webViewRef = useRef<WebView>(null)
const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/src/index.html'
const stateService = useMemo(() => new AppStateObserverService(), [])
const device = useMemo(() => new MobileDevice(stateService), [stateService])
const androidBackHandlerService = useMemo(() => new AndroidBackHandlerService(), [])
const device = useMemo(
() => new MobileDevice(stateService, androidBackHandlerService),
[androidBackHandlerService, stateService],
)
useEffect(() => {
const removeListener = stateService.addEventObserver((event: ReactNativeToWebEvent) => {
const removeStateServiceListener = stateService.addEventObserver((event: ReactNativeToWebEvent) => {
webViewRef.current?.postMessage(JSON.stringify({ reactNativeEvent: event, messageType: 'event' }))
})
const removeBackHandlerServiceListener = androidBackHandlerService.addEventObserver(
(event: ReactNativeToWebEvent) => {
webViewRef.current?.postMessage(JSON.stringify({ reactNativeEvent: event, messageType: 'event' }))
},
)
const keyboardShowListener = Keyboard.addListener('keyboardWillShow', () => {
device.reloadStatusBarStyle(false)
})
@@ -38,11 +51,12 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
})
return () => {
removeListener()
removeStateServiceListener()
removeBackHandlerServiceListener()
keyboardShowListener.remove()
keyboardHideListener.remove()
}
}, [webViewRef, stateService])
}, [webViewRef, stateService, device, androidBackHandlerService])
useEffect(() => {
const observer = device.addMobileWebEventReceiver((event) => {
@@ -84,8 +98,9 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
const WebProcessDeviceInterface = `
class WebProcessDeviceInterface {
constructor(messageSender) {
this.appVersion = '1.2.3'
this.appVersion = '${pjson.version} (${VersionInfo.buildVersion})'
this.environment = 4
this.platform = ${device.platform}
this.databases = []
this.messageSender = messageSender
}
+84
View File
@@ -3,6 +3,90 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.3.98](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-29)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.97](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-29)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.96](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-29)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.95](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.94](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.93](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.92](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.91](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.90](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.89](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.88](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.87](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.86](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.85](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-26)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.84](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-26)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.83](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-25)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.82](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-25)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.81](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.80](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.79](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.78](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.77](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/releases
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/releases",
"version": "1.3.77",
"version": "1.3.98",
"license": "AGPL-3.0-or-later",
"main": "dist/releases.json",
"types": "dist/index.d.ts",
+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.22.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/services
# [1.22.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
### Features
* handle android back button on android ([#1656](https://github.com/standardnotes/app/issues/1656)) ([981d8a7](https://github.com/standardnotes/app/commit/981d8a7497ea2600e1535ad9170cb5559550ca4d))
## [1.21.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
### Bug Fixes
* export/sharing notes on mobile webview ([#1644](https://github.com/standardnotes/app/issues/1644)) ([6d5ebde](https://github.com/standardnotes/app/commit/6d5ebdeaa1777f31878b6e7d4dfc33fe83abc9fc))
## [1.21.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/services
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/services",
"version": "1.21.4",
"version": "1.22.1",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -12,4 +12,6 @@ export interface WebApplicationInterface extends ApplicationInterface {
handleMobileResumingFromBackgroundEvent(): Promise<void>
isNativeMobileWeb(): boolean
get mobileDevice(): MobileDeviceInterface
handleAndroidBackButtonPressed(): void
addAndroidBackHandlerEventListener(listener: () => boolean): (() => void) | undefined
}
@@ -1,8 +1,9 @@
import { DeviceInterface } from './DeviceInterface'
import { Environment, RawKeychainValue } from '@standardnotes/models'
import { Environment, Platform, RawKeychainValue } from '@standardnotes/models'
export interface MobileDeviceInterface extends DeviceInterface {
environment: Environment.Mobile
platform: Platform.Ios | Platform.Android
getRawKeychainValue(): Promise<RawKeychainValue | undefined>
getDeviceBiometricsAvailability(): Promise<boolean>
@@ -12,4 +13,7 @@ export interface MobileDeviceInterface extends DeviceInterface {
stopHidingMobileInterfaceFromScreenshots(): void
consoleLog(...args: any[]): void
handleThemeSchemeChange(isDark: boolean): void
shareBase64AsFile(base64: string, filename: string): Promise<void>
downloadBase64AsFile(base64: string, filename: string, saveInTempLocation?: boolean): Promise<string | undefined>
confirmAndExit(): void
}
+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.12.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
### Features
* **crypto:** pkc algos for key generation, encrypt, and decrypt ([#1663](https://github.com/standardnotes/app/issues/1663)) ([a83003e](https://github.com/standardnotes/app/commit/a83003ee69d144c8e80eaee20bc7be2533d92c63))
## [1.11.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-08-23)
**Note:** Version bump only for package @standardnotes/sncrypto-common
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/sncrypto-common",
"version": "1.11.1",
"version": "1.12.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -26,7 +26,8 @@
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^5.30.0",
"@typescript-eslint/parser": "^5.12.1",
"eslint-plugin-prettier": "^4.2.1"
"eslint-plugin-prettier": "^4.2.1",
"typescript": "*"
},
"dependencies": {
"reflect-metadata": "^0.1.13"
@@ -0,0 +1,7 @@
import { HexString } from './HexString'
export type PkcKeyPair = {
keyType: 'curve25519' | 'ed25519' | 'x25519'
privateKey: HexString
publicKey: HexString
}
@@ -8,3 +8,4 @@ export * from './StreamDecryptorResult'
export * from './StreamEncryptor'
export * from './Unencrypted'
export * from './Utf8String'
export * from './PkcKeyPair'
+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.13.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
### Features
* **crypto:** pkc algos for key generation, encrypt, and decrypt ([#1663](https://github.com/standardnotes/app/issues/1663)) ([a83003e](https://github.com/standardnotes/app/commit/a83003ee69d144c8e80eaee20bc7be2533d92c63))
## [1.12.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-08-23)
**Note:** Version bump only for package @standardnotes/sncrypto-web
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/sncrypto-web",
"version": "1.12.3",
"version": "1.13.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -39,11 +39,13 @@
"babel-loader": "^8.2.3",
"chai": "^4.3.6",
"connect": "^3.7.0",
"eslint": "*",
"eslint-plugin-prettier": "*",
"regenerator-runtime": "^0.13.9",
"serve-static": "^1.14.2",
"ts-loader": "^9.2.6",
"typedarray-to-buffer": "^4.0.0",
"typescript": "*",
"uuid": "^8.3.2",
"webpack": "*",
"webpack-cli": "*"
+62 -17
View File
@@ -9,6 +9,7 @@ import {
PureCryptoInterface,
Utf8String,
timingSafeEqual,
PkcKeyPair,
} from '@standardnotes/sncrypto-common'
import * as Utils from './utils'
import * as sodium from './libsodium'
@@ -83,7 +84,7 @@ export class SNWebCrypto implements PureCryptoInterface {
iterations: number,
length: number,
): Promise<HexString | null> {
const keyData = await Utils.stringToArrayBuffer(password)
const keyData = Utils.stringToArrayBuffer(password)
const key = await this.webCryptoImportKey(keyData, WebCryptoAlgs.Pbkdf2, [WebCryptoActions.DeriveBits])
if (!key) {
console.error('Key is null, unable to continue')
@@ -99,21 +100,21 @@ export class SNWebCrypto implements PureCryptoInterface {
}
public async aes256CbcEncrypt(plaintext: Utf8String, iv: HexString, key: HexString): Promise<Base64String> {
const keyData = await Utils.hexStringToArrayBuffer(key)
const ivData = await Utils.hexStringToArrayBuffer(iv)
const keyData = Utils.hexStringToArrayBuffer(key)
const ivData = Utils.hexStringToArrayBuffer(iv)
const alg = { name: WebCryptoAlgs.AesCbc, iv: ivData }
const importedKeyData = await this.webCryptoImportKey(keyData, alg.name, [WebCryptoActions.Encrypt])
const textData = await Utils.stringToArrayBuffer(plaintext)
const textData = Utils.stringToArrayBuffer(plaintext)
const result = await crypto.subtle.encrypt(alg, importedKeyData, textData)
return Utils.arrayBufferToBase64(result)
}
public async aes256CbcDecrypt(ciphertext: Base64String, iv: HexString, key: HexString): Promise<Utf8String | null> {
const keyData = await Utils.hexStringToArrayBuffer(key)
const ivData = await Utils.hexStringToArrayBuffer(iv)
const keyData = Utils.hexStringToArrayBuffer(key)
const ivData = Utils.hexStringToArrayBuffer(iv)
const alg = { name: WebCryptoAlgs.AesCbc, iv: ivData }
const importedKeyData = await this.webCryptoImportKey(keyData, alg.name, [WebCryptoActions.Decrypt])
const textData = await Utils.base64ToArrayBuffer(ciphertext)
const textData = Utils.base64ToArrayBuffer(ciphertext)
try {
const result = await crypto.subtle.decrypt(alg, importedKeyData, textData)
@@ -150,11 +151,11 @@ export class SNWebCrypto implements PureCryptoInterface {
}
public async hmac1(message: Utf8String, key: HexString): Promise<HexString | null> {
const keyHexData = await Utils.hexStringToArrayBuffer(key)
const keyHexData = Utils.hexStringToArrayBuffer(key)
const keyData = await this.webCryptoImportKey(keyHexData, WebCryptoAlgs.Hmac, [WebCryptoActions.Sign], {
name: WebCryptoAlgs.Sha1,
})
const messageData = await Utils.stringToArrayBuffer(message)
const messageData = Utils.stringToArrayBuffer(message)
const funcParams = { name: WebCryptoAlgs.Hmac }
try {
@@ -169,15 +170,14 @@ export class SNWebCrypto implements PureCryptoInterface {
}
public async unsafeSha1(text: string): Promise<string> {
const textData = await Utils.stringToArrayBuffer(text)
const textData = Utils.stringToArrayBuffer(text)
const digest = await crypto.subtle.digest(WebCryptoAlgs.Sha1, textData)
return Utils.arrayBufferToHexString(digest)
}
/**
* Converts a raw string key to a WebCrypto CryptoKey object.
* @param rawKey
* A plain utf8 string or an array buffer
* @param keyData
* @param alg
* The name of the algorithm this key will be used for (i.e 'AES-CBC' or 'HMAC')
* @param actions
@@ -220,7 +220,7 @@ export class SNWebCrypto implements PureCryptoInterface {
): Promise<HexString> {
const params = {
name: WebCryptoAlgs.Pbkdf2,
salt: await Utils.stringToArrayBuffer(salt),
salt: Utils.stringToArrayBuffer(salt),
iterations: iterations,
hash: { name: WebCryptoAlgs.Sha512 },
}
@@ -298,13 +298,13 @@ export class SNWebCrypto implements PureCryptoInterface {
public xchacha20StreamEncryptorPush(
encryptor: StreamEncryptor,
plainBuffer: Uint8Array,
assocData: Utf8String,
assocData?: Utf8String,
tag: SodiumConstant = SodiumConstant.CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_PUSH,
): Uint8Array {
const encryptedBuffer = sodium.crypto_secretstream_xchacha20poly1305_push(
encryptor.state as sodium.StateAddress,
plainBuffer,
assocData.length > 0 ? Utils.stringToArrayBuffer(assocData) : null,
assocData && assocData.length > 0 ? Utils.stringToArrayBuffer(assocData) : null,
tag,
)
return encryptedBuffer
@@ -325,7 +325,7 @@ export class SNWebCrypto implements PureCryptoInterface {
public xchacha20StreamDecryptorPush(
decryptor: StreamDecryptor,
encryptedBuffer: Uint8Array,
assocData: Utf8String,
assocData?: Utf8String,
): StreamDecryptorResult | false {
if (encryptedBuffer.length < SodiumConstant.CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES) {
throw new Error('Invalid ciphertext size')
@@ -334,7 +334,7 @@ export class SNWebCrypto implements PureCryptoInterface {
const result = sodium.crypto_secretstream_xchacha20poly1305_pull(
decryptor.state as sodium.StateAddress,
encryptedBuffer,
assocData.length > 0 ? Utils.stringToArrayBuffer(assocData) : null,
assocData && assocData.length > 0 ? Utils.stringToArrayBuffer(assocData) : null,
)
if ((result as unknown) === false) {
@@ -344,6 +344,51 @@ export class SNWebCrypto implements PureCryptoInterface {
return result
}
/**
* https://doc.libsodium.org/public-key_cryptography/authenticated_encryption
*/
public sodiumCryptoBoxEasyEncrypt(
message: Utf8String,
nonce: HexString,
senderSecretKey: HexString,
recipientPublicKey: HexString,
): Base64String {
const result = sodium.crypto_box_easy(
message,
Utils.hexStringToArrayBuffer(nonce),
Utils.hexStringToArrayBuffer(recipientPublicKey),
Utils.hexStringToArrayBuffer(senderSecretKey),
)
return Utils.arrayBufferToBase64(result)
}
public sodiumCryptoBoxEasyDecrypt(
ciphertext: Base64String,
nonce: HexString,
senderPublicKey: HexString,
recipientSecretKey: HexString,
): Base64String {
const result = sodium.crypto_box_open_easy(
Utils.base64ToArrayBuffer(ciphertext),
Utils.hexStringToArrayBuffer(nonce),
Utils.hexStringToArrayBuffer(senderPublicKey),
Utils.hexStringToArrayBuffer(recipientSecretKey),
'text',
)
return result
}
public sodiumCryptoBoxGenerateKeypair(): PkcKeyPair {
const result = sodium.crypto_box_keypair()
const publicKey = Utils.arrayBufferToHexString(result.publicKey)
const privateKey = Utils.arrayBufferToHexString(result.privateKey)
return { publicKey, privateKey, keyType: result.keyType }
}
/**
* Generates a random secret for TOTP authentication
*
+3 -16
View File
@@ -1,16 +1,3 @@
export { SNWebCrypto } from './crypto'
export {
arrayBufferToBase64,
arrayBufferToHexString,
arrayBufferToString,
base64Decode,
base64Encode,
base64ToArrayBuffer,
base64ToHex,
hexStringToArrayBuffer,
hexToBase64,
isWebCryptoAvailable,
stringToArrayBuffer,
base32Decode,
base32Encode,
} from './utils'
export * from './crypto'
export * from './utils'
export { SodiumConstant } from '@standardnotes/sncrypto-common'
+7 -4
View File
@@ -3,12 +3,15 @@ export {
base64_variants,
crypto_aead_xchacha20poly1305_ietf_decrypt,
crypto_aead_xchacha20poly1305_ietf_encrypt,
crypto_secretstream_xchacha20poly1305_push,
crypto_secretstream_xchacha20poly1305_pull,
crypto_secretstream_xchacha20poly1305_init_push,
crypto_secretstream_xchacha20poly1305_init_pull,
crypto_box_easy,
crypto_box_keypair,
crypto_box_open_easy,
crypto_pwhash_ALG_DEFAULT,
crypto_pwhash,
crypto_secretstream_xchacha20poly1305_init_pull,
crypto_secretstream_xchacha20poly1305_init_push,
crypto_secretstream_xchacha20poly1305_pull,
crypto_secretstream_xchacha20poly1305_push,
from_base64,
from_hex,
from_string,
+72 -143
View File
@@ -50,31 +50,18 @@ describe('crypto operations', async function () {
it('compares strings with timing safe comparison', async function () {
const crypto = new SNWebCrypto()
expect(crypto.timingSafeEqual('hello world 🌍', 'hello world 🌍')).to.equal(
true,
)
expect(crypto.timingSafeEqual('helo world 🌍', 'hello world 🌍')).to.equal(
false,
)
expect(crypto.timingSafeEqual('hello world 🌍', 'hello world 🌍')).to.equal(true)
expect(crypto.timingSafeEqual('helo world 🌍', 'hello world 🌍')).to.equal(false)
expect(crypto.timingSafeEqual('', 'a')).to.equal(false)
expect(crypto.timingSafeEqual('', '')).to.equal(true)
expect(
crypto.timingSafeEqual(
'2e1ee7920bb188a88f94bb912153befd83cc55cd',
'2e1ee7920bb188a88f94bb912153befd83cc55cd',
),
crypto.timingSafeEqual('2e1ee7920bb188a88f94bb912153befd83cc55cd', '2e1ee7920bb188a88f94bb912153befd83cc55cd'),
).to.equal(true)
expect(
crypto.timingSafeEqual(
'1e1ee7920bb188a88f94bb912153befd83cc55cd',
'2e1ee7920bb188a88f94bb912153befd83cc55cd',
),
crypto.timingSafeEqual('1e1ee7920bb188a88f94bb912153befd83cc55cd', '2e1ee7920bb188a88f94bb912153befd83cc55cd'),
).to.equal(false)
expect(
crypto.timingSafeEqual(
'2e1ee7920bb188a88f94bb912153befd83cc55cc',
'2e1ee7920bb188a88f94bb912153befd83cc55cd',
),
crypto.timingSafeEqual('2e1ee7920bb188a88f94bb912153befd83cc55cc', '2e1ee7920bb188a88f94bb912153befd83cc55cd'),
).to.equal(false)
})
@@ -85,22 +72,16 @@ describe('crypto operations', async function () {
it('pbkdf2 1', async function () {
const password = 'very_secure🔒'
const salt =
'c3feb78823adce65c4ab024dab9c5cdcda5a04cdbd98f65eac0311dfa432d67b'
const expected =
'bbb3d3af19dd1cbb901c958003faa55f193aad6a57fff30e51a62591bdc054d8'
const salt = 'c3feb78823adce65c4ab024dab9c5cdcda5a04cdbd98f65eac0311dfa432d67b'
const expected = 'bbb3d3af19dd1cbb901c958003faa55f193aad6a57fff30e51a62591bdc054d8'
const result = await webCrypto.pbkdf2(password, salt, 100000, 256)
expect(result).to.equal(expected)
})
it('pbkdf2 2', async function () {
const password = 'correct horse battery staple ✅'
const salt = Buffer.from(
'808182838485868788898a8b8c8d8e8f',
'hex',
).toString('utf8')
const expected =
'795d83b18e55d860d3799f85a20f66ee17eb9dcf041df1d7a13fac30af7103d9'
const salt = Buffer.from('808182838485868788898a8b8c8d8e8f', 'hex').toString('utf8')
const expected = '795d83b18e55d860d3799f85a20f66ee17eb9dcf041df1d7a13fac30af7103d9'
const result = await webCrypto.pbkdf2(password, salt, 100000, 256)
expect(result).to.equal(expected)
})
@@ -116,19 +97,16 @@ describe('crypto operations', async function () {
it('hmac 256', async function () {
const text = 'hello world 🌍'
const key =
'e802dc953f3f1f7b5db62409b74ac848559d4711c4e0047ecc5e312ad8ab8397'
const key = 'e802dc953f3f1f7b5db62409b74ac848559d4711c4e0047ecc5e312ad8ab8397'
const hash = await webCrypto.hmac256(text, key)
const expected =
'b63f94ee33a067ffac3ee97c7987dd3171dcdc747a322bb3f3ab890201c8e6f9'
const expected = 'b63f94ee33a067ffac3ee97c7987dd3171dcdc747a322bb3f3ab890201c8e6f9'
expect(hash).to.equal(expected)
})
it('sha256', async function () {
const text = 'hello world 🌍'
const hash = await webCrypto.sha256(text)
const expected =
'1e71fe32476da1ff115b44dfd74aed5c90d68a1d80a2033065e30cff4335211a'
const expected = '1e71fe32476da1ff115b44dfd74aed5c90d68a1d80a2033065e30cff4335211a'
expect(hash).to.equal(expected)
})
@@ -154,13 +132,7 @@ describe('crypto operations', async function () {
const bytes = 67108864
const length = 16
const iterations = 2
const result = await webCrypto.argon2(
password,
salt,
iterations,
bytes,
length,
)
const result = await webCrypto.argon2(password, salt, iterations, bytes, length)
const expectedResult = '18dfbc268f251701652c8e38b5273f73'
expect(result).to.equal(expectedResult)
})
@@ -172,15 +144,8 @@ describe('crypto operations', async function () {
const bytes = 67108864
const length = 32
const iterations = 5
const result = await webCrypto.argon2(
password,
truncatedSalt,
iterations,
bytes,
length,
)
const expected =
'bb6ec440708c271ce34decd7f997e2444d309b1105992779ccdb47f78a5fda6f'
const result = await webCrypto.argon2(password, truncatedSalt, iterations, bytes, length)
const expected = 'bb6ec440708c271ce34decd7f997e2444d309b1105992779ccdb47f78a5fda6f'
expect(result).to.equal(expected)
})
@@ -189,69 +154,38 @@ describe('crypto operations', async function () {
const nonce = await webCrypto.generateRandomKey(192)
const plaintext = 'hello world 🌍'
const aad = JSON.stringify({ uuid: '123🎤' })
const ciphertext = await webCrypto.xchacha20Encrypt(
plaintext,
nonce,
key,
aad,
)
const decrypted = await webCrypto.xchacha20Decrypt(
ciphertext,
nonce,
key,
aad,
)
const ciphertext = await webCrypto.xchacha20Encrypt(plaintext, nonce, key, aad)
const decrypted = await webCrypto.xchacha20Decrypt(ciphertext, nonce, key, aad)
expect(decrypted).to.equal(plaintext)
})
it('xchacha20 streaming encrypt/decrypt', async function () {
const key = await webCrypto.generateRandomKey(256)
const bigFile = await fetch(
'http://localhost:9003/test/resources/big_file.md',
)
const bigFile = await fetch('http://localhost:9003/test/resources/big_file.md')
const bigText = await bigFile.text()
const plaintext = bigText
const plainBuffer = stringToArrayBuffer(plaintext)
const encryptor = webCrypto.xchacha20StreamEncryptInitEncryptor(key)
const header = base64StringToArrayBuffer(encryptor.header)
const encryptor = webCrypto.xchacha20StreamInitEncryptor(key)
let encryptedBuffer = Buffer.concat([header])
const headerBase64 = encryptor.header
const headerBuffer = base64ToArrayBuffer(encryptor.header)
let encryptedBuffer = Buffer.concat([headerBuffer])
const pushChunkSize = plainBuffer.length / 200
const pullChunkSize =
pushChunkSize +
SodiumConstant.CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES
const pullChunkSize = pushChunkSize + SodiumConstant.CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES
for (let i = 0; i < plainBuffer.length; i += pushChunkSize) {
const readUntil =
i + pushChunkSize > plainBuffer.length
? plainBuffer.length
: i + pushChunkSize
const chunk = webCrypto.xchacha20StreamEncryptorPush(
encryptor,
plainBuffer.slice(i, readUntil),
)
const readUntil = i + pushChunkSize > plainBuffer.length ? plainBuffer.length : i + pushChunkSize
const chunk = webCrypto.xchacha20StreamEncryptorPush(encryptor, plainBuffer.slice(i, readUntil))
encryptedBuffer = Buffer.concat([encryptedBuffer, chunk])
}
const decryptor = webCrypto.xchacha20StreamEncryptInitDecryptor(
header,
key,
)
const decryptor = webCrypto.xchacha20StreamInitDecryptor(headerBase64, key)
let decryptedBuffer = Buffer.alloc(0)
for (
let i = header.length;
i < encryptedBuffer.length;
i += pullChunkSize
) {
const readUntil =
i + pullChunkSize > encryptedBuffer.length
? encryptedBuffer.length
: i + pullChunkSize
const chunk = webCrypto.xchacha20StreamDecryptorPush(
decryptor,
encryptedBuffer.slice(i, readUntil),
)
for (let i = headerBuffer.length; i < encryptedBuffer.length; i += pullChunkSize) {
const readUntil = i + pullChunkSize > encryptedBuffer.length ? encryptedBuffer.length : i + pullChunkSize
const chunk = webCrypto.xchacha20StreamDecryptorPush(decryptor, encryptedBuffer.slice(i, readUntil))
decryptedBuffer = Buffer.concat([decryptedBuffer, chunk.message])
}
@@ -263,18 +197,8 @@ describe('crypto operations', async function () {
const key = await webCrypto.generateRandomKey(256)
const nonce = await webCrypto.generateRandomKey(192)
const plaintext = 'hello world 🌍'
const ciphertext = await webCrypto.xchacha20Encrypt(
plaintext,
nonce,
key,
JSON.stringify({ uuid: 'foo🎲' }),
)
const result = await webCrypto.xchacha20Decrypt(
ciphertext,
nonce,
key,
JSON.stringify({ uuid: 'bar🎲' }),
)
const ciphertext = await webCrypto.xchacha20Encrypt(plaintext, nonce, key, JSON.stringify({ uuid: 'foo🎲' }))
const result = await webCrypto.xchacha20Decrypt(ciphertext, nonce, key, JSON.stringify({ uuid: 'bar🎲' }))
expect(result).to.not.be.ok
})
@@ -284,23 +208,12 @@ describe('crypto operations', async function () {
"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it.🌞"
const assocData = await hexStringToArrayBuffer('50515253c0c1c2c3c4c5c6c7')
const nonce = '404142434445464748494a4b4c4d4e4f5051525354555657'
const key =
'808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f'
const ciphertext = await webCrypto.xchacha20Encrypt(
plaintext,
nonce,
key,
assocData,
)
const key = '808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f'
const ciphertext = await webCrypto.xchacha20Encrypt(plaintext, nonce, key, assocData)
const expected =
'vW0XnT6D1DuVdleUk8DpOVcqFwAlK/rMvtKQLCE5bLtzHH8bC0qmRAvzqC9O2n45rmTGcIxUwhbLlrcuEhO0Ui+Mm6QNtdlFsRtpuYLBu54/P6wrw2lIj3ayODVl0//5IflmTJdjfal2iBL2FcaLE7UuOqw6kdl7HV6PKzn0pIOeHH3rkwQ='
expect(ciphertext).to.equal(expected)
const decrypted = await webCrypto.xchacha20Decrypt(
ciphertext,
nonce,
key,
assocData,
)
const decrypted = await webCrypto.xchacha20Decrypt(ciphertext, nonce, key, assocData)
expect(decrypted).to.equal(plaintext)
})
@@ -315,16 +228,10 @@ describe('crypto operations', async function () {
).toString('utf8')
const assocData = Buffer.from('50515253c0c1c2c3c4c5c6c7', 'hex')
const nonce = '404142434445464748494a4b4c4d4e4f5051525354555657'
const key =
'808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f'
const key = '808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f'
/** Encrypt */
const ciphertextBase64 = await webCrypto.xchacha20Encrypt(
plaintext,
nonce,
key,
assocData,
)
const ciphertextBase64 = await webCrypto.xchacha20Encrypt(plaintext, nonce, key, assocData)
const ciphertextHex = await base64ToHex(ciphertextBase64)
const expected =
'bd6d179d3e83d43b9576579493c0e939572a1700252bfaccbed2902c21396cbb' +
@@ -335,12 +242,7 @@ describe('crypto operations', async function () {
expect(ciphertextHex).to.equal(expected)
/** Decrypt */
const decrypted = await webCrypto.xchacha20Decrypt(
ciphertextBase64,
nonce,
key,
assocData,
)
const decrypted = await webCrypto.xchacha20Decrypt(ciphertextBase64, nonce, key, assocData)
expect(decrypted).to.equal(plaintext)
})
@@ -351,17 +253,44 @@ describe('crypto operations', async function () {
const bytes = 67108864
const length = 16
const iterations = 2
const result = await webCrypto.argon2(
password,
salt,
iterations,
bytes,
length,
)
const result = await webCrypto.argon2(password, salt, iterations, bytes, length)
const expectedResult = '720f95400220748a811bca9b8cff5d6e'
expect(result).to.equal(expectedResult)
})
it('pkc crypto_box_easy keypair generation', async function () {
const keypair = await webCrypto.sodiumCryptoBoxGenerateKeypair()
expect(keypair.keyType).to.equal('x25519')
expect(keypair.publicKey.length).to.equal(64)
expect(keypair.privateKey.length).to.equal(64)
})
it('pkc crypto_box_easy encrypt/decrypt', async function () {
const senderKeypair = await webCrypto.sodiumCryptoBoxGenerateKeypair()
const recipientKeypair = await webCrypto.sodiumCryptoBoxGenerateKeypair()
const nonce = await webCrypto.generateRandomKey(192)
const plaintext = 'hello world 🌍'
const ciphertext = await webCrypto.sodiumCryptoBoxEasyEncrypt(
plaintext,
nonce,
senderKeypair.privateKey,
recipientKeypair.publicKey,
)
expect(ciphertext.length).to.equal(44)
const decrypted = await webCrypto.sodiumCryptoBoxEasyDecrypt(
ciphertext,
nonce,
senderKeypair.publicKey,
recipientKeypair.privateKey,
)
expect(decrypted).to.equal(plaintext)
})
it('generates random OTP secret 160 bits long', async function () {
const secret = await webCrypto.generateOtpSecret()
expect(secret).to.have.length(32)
+1 -1
View File
@@ -6,7 +6,7 @@
<link rel="stylesheet" href="https://unpkg.com/mocha/mocha.css" />
<script src="https://unpkg.com/chai/chai.js"></script>
<script src="https://unpkg.com/mocha/mocha.js"></script>
<script src="../../../node_modules/regenerator-runtime/runtime.js"></script>
<script src="https://unpkg.com/regenerator-runtime@0.13.9/runtime.js"></script>
<script src="../dist/sncrypto-web.js"></script>
<script>
Object.assign(window, SNCrypto);
+1 -1
View File
@@ -42,7 +42,7 @@ describe('utils', async function () {
it('base64URLEncode', async function () {
const str = 'hello world'
const b64 = await base64Encode(str)
const b64 = await base64URLEncode(str)
expect(b64).to.equal('aGVsbG8gd29ybGQ')
})
+21
View File
@@ -3,6 +3,27 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.133.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-29)
### Bug Fixes
* **snjs:** increase wait time in retrieving settings test for processing replaceable settings ([3322600](https://github.com/standardnotes/app/commit/33226000f58f5b7f97882479e62de3b24a6b217d))
* **snjs:** instructions on running e2e locally ([c841ac9](https://github.com/standardnotes/app/commit/c841ac99d2e51ecca05f4a349efa1a0acb09a86d))
## [2.133.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/snjs
# [2.133.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
### Features
* handle android back button on android ([#1656](https://github.com/standardnotes/app/issues/1656)) ([981d8a7](https://github.com/standardnotes/app/commit/981d8a7497ea2600e1535ad9170cb5559550ca4d))
## [2.132.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/snjs
## [2.132.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/snjs
+7 -1
View File
@@ -44,8 +44,9 @@ To run a stable server environment for E2E tests that is up to date with product
```
yarn install --immutable
yarn test:stable-server
yarn start:local-server
```
Wait for the `All services are up!` message.
Once the server infrastructure is ready, and you've built all packages, you can run the test suite in the browser via:
@@ -53,6 +54,11 @@ Once the server infrastructure is ready, and you've built all packages, you can
yarn start:server:e2e
```
Once you are finished you can close the running local server on E2E repo by typing:
```
yarn stop:local-server
```
### Unit Tests
From the root of the repository, run:
@@ -3,4 +3,5 @@ export enum ReactNativeToWebEvent {
ResumingFromBackground = 'ResumingFromBackground',
GainingFocus = 'GainingFocus',
LosingFocus = 'LosingFocus',
AndroidBackButtonPressed = 'AndroidBackButtonPressed',
}
+1 -1
View File
@@ -103,7 +103,7 @@ describe('settings service', function () {
offline: false,
})
await Factory.sleep(0.5)
await Factory.sleep(1)
const setting = await application.settings.getSubscriptionSetting('FILE_UPLOAD_BYTES_LIMIT')
expect(setting).to.be.a('string')
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/snjs",
"version": "2.132.5",
"version": "2.133.2",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+20
View File
@@ -3,6 +3,26 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.3.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/ui-services
## [1.3.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
**Note:** Version bump only for package @standardnotes/ui-services
## [1.3.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
### Bug Fixes
* **web:** incorrect native access ([b9a07d8](https://github.com/standardnotes/app/commit/b9a07d8b5abb9ae6e7fb00b0edc1fa87a2b209e0))
## [1.3.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
### Bug Fixes
* export/sharing notes on mobile webview ([#1644](https://github.com/standardnotes/app/issues/1644)) ([6d5ebde](https://github.com/standardnotes/app/commit/6d5ebdeaa1777f31878b6e7d4dfc33fe83abc9fc))
## [1.3.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
**Note:** Version bump only for package @standardnotes/ui-services
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/ui-services",
"version": "1.3.2",
"version": "1.3.6",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -8,7 +8,7 @@ import {
import { ContentType } from '@standardnotes/common'
import { ApplicationInterface } from '@standardnotes/services'
function sanitizeFileName(name: string): string {
export function sanitizeFileName(name: string): string {
return name.trim().replace(/[.\\/:"?*|<>]/g, '_')
}
@@ -52,13 +52,16 @@ export class ArchiveManager {
})
if (encrypted) {
this.downloadData(blobData, `Standard Notes Encrypted Backup and Import File - ${this.formattedDate()}.txt`)
this.downloadData(
blobData,
`Standard Notes Encrypted Backup and Import File - ${this.formattedDateForExports()}.txt`,
)
} else {
this.downloadZippedDecryptedItems(data).catch(console.error)
}
}
private formattedDate() {
formattedDateForExports() {
const string = `${new Date()}`
// Match up to the first parenthesis, i.e do not include '(Central Standard Time)'
const matches = string.match(/^(.*?) \(/)
@@ -108,7 +111,7 @@ export class ArchiveManager {
await nextFile()
} else {
const finalBlob = await zipWriter.close()
this.downloadData(finalBlob, `Standard Notes Backup - ${this.formattedDate()}.zip`)
this.downloadData(finalBlob, `Standard Notes Backup - ${this.formattedDateForExports()}.zip`)
}
}
@@ -143,7 +146,7 @@ export class ArchiveManager {
async downloadDataAsZip(data: ZippableData) {
const zipFileAsBlob = await this.zipData(data)
this.downloadData(zipFileAsBlob, `Standard Notes Export - ${this.formattedDate()}.zip`)
this.downloadData(zipFileAsBlob, `Standard Notes Export - ${this.formattedDateForExports()}.zip`)
}
private hrefForData(data: Blob) {
@@ -323,7 +323,7 @@ export class ThemeManager extends AbstractService {
removeFromArray(this.activeThemes, uuid)
if (this.activeThemes.length === 0) {
if (this.activeThemes.length === 0 && this.application.isNativeMobileWeb()) {
this.application.mobileDevice.handleThemeSchemeChange(false)
}
}
+111
View File
@@ -3,6 +3,117 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.63.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-29)
**Note:** Version bump only for package @standardnotes/web
## [3.63.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-29)
### Bug Fixes
* ipad web view ui improvements ([#1664](https://github.com/standardnotes/app/issues/1664)) ([20e4208](https://github.com/standardnotes/app/commit/20e420820d45e2556f60604de41a1bee96c98fe1))
# [3.63.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-29)
### Features
* add panel settings section in quick settings ([#1669](https://github.com/standardnotes/app/issues/1669)) ([0ed692f](https://github.com/standardnotes/app/commit/0ed692ff9b056d00dde73040482e6b1bdc404d04))
## [3.62.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
### Bug Fixes
* improve biometrics input on mobile webview ([#1658](https://github.com/standardnotes/app/issues/1658)) ([3f9d3ce](https://github.com/standardnotes/app/commit/3f9d3ceffac206645ff9e5b66e68bf2e7b9cd0e2))
## [3.62.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
### Bug Fixes
* grey flash on textarea focus on safari/webkit ([#1661](https://github.com/standardnotes/app/issues/1661)) ([eafa65e](https://github.com/standardnotes/app/commit/eafa65e93487e60a4b9ee37f8cc9932bbbd8917c))
## [3.62.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
### Bug Fixes
* sticky header behavior on iOS ([#1662](https://github.com/standardnotes/app/issues/1662)) ([c0af596](https://github.com/standardnotes/app/commit/c0af596f2d69654b50dffc36250137998dc629fb))
## [3.62.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
### Bug Fixes
* challenge modal ([9502a7f](https://github.com/standardnotes/app/commit/9502a7fa32f2cca26b6964788a44acc9778bf721))
* webapp blanking because of mobileDevice check ([727d78c](https://github.com/standardnotes/app/commit/727d78cfe23c268dcd97566f355584a23c8b9d45))
# [3.62.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-28)
### Features
* handle android back button on android ([#1656](https://github.com/standardnotes/app/issues/1656)) ([981d8a7](https://github.com/standardnotes/app/commit/981d8a7497ea2600e1535ad9170cb5559550ca4d))
# [3.61.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
### Features
* delete account ([#1653](https://github.com/standardnotes/app/issues/1653)) ([5db47e8](https://github.com/standardnotes/app/commit/5db47e83e82993bb9d210cb264d05f59ea1db5f3))
# [3.60.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
### Features
* upgrade cta ([#1655](https://github.com/standardnotes/app/issues/1655)) ([ec52e56](https://github.com/standardnotes/app/commit/ec52e5684da2c7caaa24929275727cdbc23c28e1))
## [3.59.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
**Note:** Version bump only for package @standardnotes/web
## [3.59.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
### Bug Fixes
* don't hide note tags container on desktop ([70e57e3](https://github.com/standardnotes/app/commit/70e57e32b3bd6e3943d6fc1af2ae0ad9618e1812))
## [3.59.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-27)
### Bug Fixes
* export/sharing notes on mobile webview ([#1644](https://github.com/standardnotes/app/issues/1644)) ([6d5ebde](https://github.com/standardnotes/app/commit/6d5ebdeaa1777f31878b6e7d4dfc33fe83abc9fc))
# [3.59.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-26)
### Features
* sticky header on mobile if plaintext note is scrolled ([#1645](https://github.com/standardnotes/app/issues/1645)) ([f8f6a52](https://github.com/standardnotes/app/commit/f8f6a5212b608c7a43957449d42b1d462134773c))
## [3.58.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-26)
### Bug Fixes
* **web:** check for proper feature in the subscription sharing section ([507823d](https://github.com/standardnotes/app/commit/507823dc4f1a2c1db47635ac26a686d4d8f2f4e8))
# [3.58.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-25)
### Features
* note tags button for mobile ([#1641](https://github.com/standardnotes/app/issues/1641)) ([fe2ce9f](https://github.com/standardnotes/app/commit/fe2ce9f1e8b0c027c30bcd892606e914f71d4634))
## [3.57.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
### Bug Fixes
* improve new note title format ([#1640](https://github.com/standardnotes/app/issues/1640)) ([59e814b](https://github.com/standardnotes/app/commit/59e814bcec8c3633b1b2baeae3e435fa2a19777d))
## [3.57.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
### Bug Fixes
* move prefs button next to account menu button in footer ([#1639](https://github.com/standardnotes/app/issues/1639)) ([a3611c8](https://github.com/standardnotes/app/commit/a3611c802f08817d2ba73adf6b9d48465738b1b6))
## [3.57.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
### Bug Fixes
* responsiveness when resizing window on web/desktop ([#1637](https://github.com/standardnotes/app/issues/1637)) ([e4b1b64](https://github.com/standardnotes/app/commit/e4b1b641194cede65b8f0413bb1c805a84585dfe))
# [3.57.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/web",
"version": "3.57.0",
"version": "3.63.2",
"license": "AGPL-3.0-or-later",
"main": "dist/app.js",
"author": "Standard Notes.",
+10 -4
View File
@@ -41,6 +41,13 @@ const getKey = () => {
return keyCount++
}
const setViewportHeight = () => {
document.documentElement.style.setProperty(
'--viewport-height',
`${visualViewport ? visualViewport.height : window.innerHeight}px`,
)
}
const startApplication: StartApplication = async function startApplication(
defaultSyncServerHost: string,
device: WebOrDesktopDevice,
@@ -53,6 +60,7 @@ const startApplication: StartApplication = async function startApplication(
let root: Root
const onDestroy = () => {
window.removeEventListener('orientationchange', setViewportHeight)
const rootElement = document.getElementById(ElementIds.RootId) as HTMLElement
root.unmount()
rootElement.remove()
@@ -66,10 +74,8 @@ const startApplication: StartApplication = async function startApplication(
root = createRoot(appendedRootNode)
disableIosTextFieldZoom()
document.documentElement.style.setProperty(
'--viewport-height',
`${visualViewport ? visualViewport.height : window.innerHeight}px`,
)
setViewportHeight()
window.addEventListener('orientationchange', setViewportHeight)
root.render(
<ApplicationGroupView
@@ -26,6 +26,7 @@ import { isDesktopApplication } from '@/Utils'
import { DesktopManager } from './Device/DesktopManager'
import { ArchiveManager, AutolockService, IOService, WebAlertService, ThemeManager } from '@standardnotes/ui-services'
import { MobileWebReceiver } from './MobileWebReceiver'
import { AndroidBackHandler } from '@/NativeMobileWeb/AndroidBackHandler'
type WebServices = {
viewControllerManager: ViewControllerManager
@@ -45,6 +46,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter
public iconsController: IconsController
private onVisibilityChange: () => void
private mobileWebReceiver?: MobileWebReceiver
private androidBackHandler?: AndroidBackHandler
constructor(
deviceInterface: WebOrDesktopDevice,
@@ -76,6 +78,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter
if (this.isNativeMobileWeb()) {
this.mobileWebReceiver = new MobileWebReceiver(this)
this.androidBackHandler = new AndroidBackHandler()
// eslint-disable-next-line no-console
console.log = (...args) => {
@@ -264,4 +267,17 @@ export class WebApplication extends SNApplication implements WebApplicationInter
await this.lock()
}
}
handleAndroidBackButtonPressed(): void {
if (typeof this.androidBackHandler !== 'undefined') {
this.androidBackHandler.notifyEvent()
}
}
addAndroidBackHandlerEventListener(listener: () => boolean) {
if (typeof this.androidBackHandler !== 'undefined') {
return this.androidBackHandler.addEventListener(listener)
}
return
}
}
@@ -20,7 +20,7 @@ const createApplication = (
device: WebOrDesktopDevice,
webSocketUrl: string,
) => {
const platform = getPlatform()
const platform = getPlatform(device)
const application = new WebApplication(
deviceInterface,
@@ -53,6 +53,9 @@ export class MobileWebReceiver {
case ReactNativeToWebEvent.ResumingFromBackground:
void this.application.handleMobileResumingFromBackgroundEvent()
break
case ReactNativeToWebEvent.AndroidBackButtonPressed:
void this.application.handleAndroidBackButtonPressed()
break
default:
break
@@ -12,7 +12,7 @@ import PreferencesViewWrapper from '@/Components/Preferences/PreferencesViewWrap
import ChallengeModal from '@/Components/ChallengeModal/ChallengeModal'
import NotesContextMenu from '@/Components/NotesContextMenu/NotesContextMenu'
import PurchaseFlowWrapper from '@/Components/PurchaseFlow/PurchaseFlowWrapper'
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react'
import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import RevisionHistoryModal from '@/Components/RevisionHistoryModal/RevisionHistoryModal'
import PremiumModalProvider from '@/Hooks/usePremiumModal'
import ConfirmSignoutContainer from '@/Components/ConfirmSignoutModal/ConfirmSignoutModal'
@@ -25,6 +25,8 @@ import { PanelResizedData } from '@/Types/PanelResizedData'
import TagContextMenuWrapper from '@/Components/Tags/TagContextMenuWrapper'
import FileDragNDropProvider from '../FileDragNDropProvider/FileDragNDropProvider'
import ResponsivePaneProvider from '../ResponsivePane/ResponsivePaneProvider'
import AndroidBackHandlerProvider from '@/NativeMobileWeb/useAndroidBackHandler'
import ConfirmDeleteAccountContainer from '@/Components/ConfirmDeleteAccountModal/ConfirmDeleteAccountModal'
type Props = {
application: WebApplication
@@ -33,13 +35,14 @@ type Props = {
const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicationGroup }) => {
const platformString = getPlatformString()
const [appClass, setAppClass] = useState('')
const [launched, setLaunched] = useState(false)
const [needsUnlock, setNeedsUnlock] = useState(true)
const [challenges, setChallenges] = useState<Challenge[]>([])
const viewControllerManager = application.getViewControllerManager()
const appColumnContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const desktopService = application.getDesktopService()
@@ -123,15 +126,27 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
useEffect(() => {
const removeObserver = application.addWebEventObserver(async (eventName, data) => {
if (eventName === WebAppEvent.PanelResized) {
if (!appColumnContainerRef.current) {
return
}
const { panel, collapsed } = data as PanelResizedData
let appClass = ''
if (panel === PANEL_NAME_NOTES && collapsed) {
appClass += 'collapsed-notes'
if (panel === PANEL_NAME_NOTES) {
if (collapsed) {
appColumnContainerRef.current.classList.add('collapsed-notes')
} else {
appColumnContainerRef.current.classList.remove('collapsed-notes')
}
}
if (panel === PANEL_NAME_NAVIGATION && collapsed) {
appClass += ' collapsed-navigation'
if (panel === PANEL_NAME_NAVIGATION) {
if (collapsed) {
appColumnContainerRef.current.classList.add('collapsed-navigation')
} else {
appColumnContainerRef.current.classList.remove('collapsed-navigation')
}
}
setAppClass(appClass)
} else if (eventName === WebAppEvent.WindowDidFocus) {
if (!(await application.isLocked())) {
application.sync.sync().catch(console.error)
@@ -150,7 +165,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
const renderChallenges = useCallback(() => {
return (
<>
<AndroidBackHandlerProvider application={application}>
{challenges.map((challenge) => {
return (
<div className="sk-modal" key={`${challenge.id}${application.ephemeralIdentifier}`}>
@@ -165,7 +180,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
</div>
)
})}
</>
</AndroidBackHandlerProvider>
)
}, [viewControllerManager, challenges, mainApplicationGroup, removeChallenge, application])
@@ -174,76 +189,79 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
}
return (
<PremiumModalProvider application={application} viewControllerManager={viewControllerManager}>
<AndroidBackHandlerProvider application={application}>
<ResponsivePaneProvider>
<div className={platformString + ' main-ui-view sn-component'}>
<div id="app" className={appClass + ' app app-column-container'}>
<FileDragNDropProvider
application={application}
featuresController={viewControllerManager.featuresController}
filesController={viewControllerManager.filesController}
>
<Navigation application={application} />
<ContentListView
<PremiumModalProvider application={application} viewControllerManager={viewControllerManager}>
<div className={platformString + ' main-ui-view sn-component'}>
<div id="app" className="app app-column-container" ref={appColumnContainerRef}>
<FileDragNDropProvider
application={application}
accountMenuController={viewControllerManager.accountMenuController}
featuresController={viewControllerManager.featuresController}
filesController={viewControllerManager.filesController}
itemListController={viewControllerManager.itemListController}
navigationController={viewControllerManager.navigationController}
noAccountWarningController={viewControllerManager.noAccountWarningController}
noteTagsController={viewControllerManager.noteTagsController}
>
<Navigation application={application} />
<ContentListView
application={application}
accountMenuController={viewControllerManager.accountMenuController}
filesController={viewControllerManager.filesController}
itemListController={viewControllerManager.itemListController}
navigationController={viewControllerManager.navigationController}
noAccountWarningController={viewControllerManager.noAccountWarningController}
noteTagsController={viewControllerManager.noteTagsController}
notesController={viewControllerManager.notesController}
selectionController={viewControllerManager.selectionController}
searchOptionsController={viewControllerManager.searchOptionsController}
/>
<NoteGroupView application={application} />
</FileDragNDropProvider>
</div>
<>
<Footer application={application} applicationGroup={mainApplicationGroup} />
<SessionsModal application={application} viewControllerManager={viewControllerManager} />
<PreferencesViewWrapper viewControllerManager={viewControllerManager} application={application} />
<RevisionHistoryModal
application={application}
historyModalController={viewControllerManager.historyModalController}
notesController={viewControllerManager.notesController}
selectionController={viewControllerManager.selectionController}
searchOptionsController={viewControllerManager.searchOptionsController}
subscriptionController={viewControllerManager.subscriptionController}
/>
<NoteGroupView application={application} />
</FileDragNDropProvider>
</>
{renderChallenges()}
<>
<NotesContextMenu
application={application}
navigationController={viewControllerManager.navigationController}
notesController={viewControllerManager.notesController}
noteTagsController={viewControllerManager.noteTagsController}
historyModalController={viewControllerManager.historyModalController}
/>
<TagContextMenuWrapper
navigationController={viewControllerManager.navigationController}
featuresController={viewControllerManager.featuresController}
/>
<FileContextMenuWrapper
filesController={viewControllerManager.filesController}
selectionController={viewControllerManager.selectionController}
/>
<PurchaseFlowWrapper application={application} viewControllerManager={viewControllerManager} />
<ConfirmSignoutContainer
applicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
application={application}
/>
<ToastContainer />
<FilePreviewModalWrapper application={application} viewControllerManager={viewControllerManager} />
<PermissionsModalWrapper application={application} />
<ConfirmDeleteAccountContainer application={application} viewControllerManager={viewControllerManager} />
</>
</div>
<>
<Footer application={application} applicationGroup={mainApplicationGroup} />
<SessionsModal application={application} viewControllerManager={viewControllerManager} />
<PreferencesViewWrapper viewControllerManager={viewControllerManager} application={application} />
<RevisionHistoryModal
application={application}
historyModalController={viewControllerManager.historyModalController}
notesController={viewControllerManager.notesController}
selectionController={viewControllerManager.selectionController}
subscriptionController={viewControllerManager.subscriptionController}
/>
</>
{renderChallenges()}
<>
<NotesContextMenu
application={application}
navigationController={viewControllerManager.navigationController}
notesController={viewControllerManager.notesController}
noteTagsController={viewControllerManager.noteTagsController}
historyModalController={viewControllerManager.historyModalController}
/>
<TagContextMenuWrapper
navigationController={viewControllerManager.navigationController}
featuresController={viewControllerManager.featuresController}
/>
<FileContextMenuWrapper
filesController={viewControllerManager.filesController}
selectionController={viewControllerManager.selectionController}
/>
<PurchaseFlowWrapper application={application} viewControllerManager={viewControllerManager} />
<ConfirmSignoutContainer
applicationGroup={mainApplicationGroup}
viewControllerManager={viewControllerManager}
application={application}
/>
<ToastContainer />
<FilePreviewModalWrapper application={application} viewControllerManager={viewControllerManager} />
<PermissionsModalWrapper application={application} />
</>
</div>
</PremiumModalProvider>
</ResponsivePaneProvider>
</PremiumModalProvider>
</AndroidBackHandlerProvider>
)
}
@@ -0,0 +1,44 @@
import { WebApplication } from '@/Application/Application'
import { ChallengePrompt } from '@standardnotes/services'
import { RefObject, useState } from 'react'
import Button from '../Button/Button'
import Icon from '../Icon/Icon'
import { InputValue } from './InputValue'
type Props = {
application: WebApplication
onValueChange: (value: InputValue['value'], prompt: ChallengePrompt) => void
prompt: ChallengePrompt
buttonRef: RefObject<HTMLButtonElement>
}
const BiometricsPrompt = ({ application, onValueChange, prompt, buttonRef }: Props) => {
const [authenticated, setAuthenticated] = useState(false)
return (
<div className="min-w-76">
<Button
primary
fullWidth
colorStyle={authenticated ? 'success' : 'info'}
onClick={async () => {
const authenticated = await application.mobileDevice.authenticateWithBiometrics()
setAuthenticated(authenticated)
onValueChange(authenticated, prompt)
}}
ref={buttonRef}
>
{authenticated ? (
<span className="flex items-center justify-center gap-3">
<Icon type="check-circle" />
Biometrics successful
</span>
) : (
'Tap to use biometrics'
)}
</Button>
</div>
)
}
export default BiometricsPrompt
@@ -184,6 +184,20 @@ const ChallengeModal: FunctionComponent<Props> = ({
}
}, [hasBiometricPromptValue, hasOnlyBiometricPrompt, submit])
useEffect(() => {
const removeListener = application.addAndroidBackHandlerEventListener(() => {
if (challenge.cancelable) {
cancelChallenge()
}
return true
})
return () => {
if (removeListener) {
removeListener()
}
}
}, [application, cancelChallenge, challenge.cancelable])
if (!challenge.prompts) {
return null
}
@@ -1,16 +1,11 @@
import {
ChallengePrompt,
ChallengeValidation,
MobileDeviceInterface,
ProtectionSessionDurations,
} from '@standardnotes/snjs'
import { ChallengePrompt, ChallengeValidation, ProtectionSessionDurations } from '@standardnotes/snjs'
import { FunctionComponent, useEffect, useRef } from 'react'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import { ChallengeModalValues } from './ChallengeModalValues'
import Button from '../Button/Button'
import { WebApplication } from '@/Application/Application'
import { InputValue } from './InputValue'
import BiometricsPrompt from './BiometricsPrompt'
type Props = {
application: WebApplication
@@ -87,21 +82,12 @@ const ChallengeModalPrompt: FunctionComponent<Props> = ({
</div>
</div>
) : prompt.validation === ChallengeValidation.Biometric ? (
<div className="min-w-76">
<Button
primary
fullWidth
onClick={async () => {
const authenticated = await (
application.deviceInterface as MobileDeviceInterface
).authenticateWithBiometrics()
onValueChange(authenticated, prompt)
}}
ref={biometricsButtonRef}
>
Tap to use biometrics
</Button>
</div>
<BiometricsPrompt
application={application}
onValueChange={onValueChange}
prompt={prompt}
buttonRef={biometricsButtonRef}
/>
) : prompt.secureTextEntry ? (
<DecoratedPasswordInput
ref={inputRef}
@@ -0,0 +1,70 @@
import { observer } from 'mobx-react-lite'
import { ViewControllerManager } from '@Controllers/ViewControllerManager'
import { AlertDialog, AlertDialogDescription, AlertDialogLabel } from '@reach/alert-dialog'
import { useRef } from 'react'
import { STRING_DELETE_ACCOUNT_CONFIRMATION } from '@/Constants/Strings'
import Button from '@/Components/Button/Button'
import { WebApplication } from '@/Application/Application'
type Props = {
viewControllerManager: ViewControllerManager
application: WebApplication
}
const ConfirmDeleteAccountModal = ({ application, viewControllerManager }: Props) => {
function closeDialog() {
viewControllerManager.accountMenuController.setDeletingAccount(false)
}
const cancelRef = useRef<HTMLButtonElement>(null)
return (
<AlertDialog onDismiss={closeDialog} leastDestructiveRef={cancelRef} className="max-w-[600px] p-0">
<div className="sk-modal-content">
<div className="sn-component">
<div className="sk-panel">
<div className="sk-panel-content">
<div className="sk-panel-section">
<AlertDialogLabel className="sk-h3 sk-panel-section-title">Delete account?</AlertDialogLabel>
<AlertDialogDescription className="sk-panel-row">
<div>
<p className="text-foreground">{STRING_DELETE_ACCOUNT_CONFIRMATION}</p>
</div>
</AlertDialogDescription>
<div className="my-1 mt-4 flex gap-2">
<Button primary small colorStyle="neutral" rounded={false} ref={cancelRef} onClick={closeDialog}>
Cancel
</Button>
<Button
primary
small
colorStyle="danger"
rounded={false}
onClick={() => {
application.user.deleteAccount().catch(console.error)
closeDialog()
}}
>
Delete my account for good
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</AlertDialog>
)
}
ConfirmDeleteAccountModal.displayName = 'ConfirmDeleteAccountModal'
const ConfirmDeleteAccountContainer = (props: Props) => {
if (!props.viewControllerManager.accountMenuController.deletingAccount) {
return null
}
return <ConfirmDeleteAccountModal {...props} />
}
export default observer(ConfirmDeleteAccountContainer)
@@ -69,7 +69,7 @@ const ContentList: FunctionComponent<Props> = ({
<div
className={classNames(
'infinite-scroll overflow-y-auto overflow-x-hidden focus:shadow-none focus:outline-none',
'md:max-h-full md:overflow-y-hidden md:hover:overflow-y-auto',
'md:max-h-full md:overflow-y-hidden md:hover:overflow-y-auto pointer-coarse:md:overflow-y-auto',
'md:hover:[overflow-y:_overlay]',
)}
id={ElementIds.ContentList}
@@ -24,6 +24,7 @@ import { StreamingFileReader } from '@standardnotes/filepicker'
import SearchBar from '../SearchBar/SearchBar'
import { SearchOptionsController } from '@/Controllers/SearchOptionsController'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
type Props = {
accountMenuController: AccountMenuController
@@ -50,7 +51,7 @@ const ContentListView: FunctionComponent<Props> = ({
selectionController,
searchOptionsController,
}) => {
const { toggleAppPane } = useResponsiveAppPane()
const { isNotesListVisibleOnTablets, toggleAppPane } = useResponsiveAppPane()
const fileInputRef = useRef<HTMLInputElement>(null)
const itemsViewPanelRef = useRef<HTMLDivElement>(null)
@@ -181,12 +182,19 @@ const ContentListView: FunctionComponent<Props> = ({
[isFilesSmartView],
)
const matchesMediumBreakpoint = useMediaQuery(MediaQueryBreakpoints.md)
const matchesXLBreakpoint = useMediaQuery(MediaQueryBreakpoints.xl)
const isTabletScreenSize = matchesMediumBreakpoint && !matchesXLBreakpoint
return (
<div
id="items-column"
className={classNames(
'sn-component section app-column flex h-screen flex-col pt-safe-top md:h-full',
'xl:w-87.5 xsm-only:!w-full sm-only:!w-full pointer-coarse:md-only:!w-52 pointer-coarse:lg-only:!w-52',
'xl:w-87.5 xsm-only:!w-full sm-only:!w-full',
isTabletScreenSize && !isNotesListVisibleOnTablets
? 'pointer-coarse:md-only:!w-0 pointer-coarse:lg-only:!w-0'
: 'pointer-coarse:md-only:!w-60 pointer-coarse:lg-only:!w-60',
)}
aria-label={'Notes & Files'}
ref={itemsViewPanelRef}
@@ -19,6 +19,7 @@ import { EditorEventSource } from '@/Types/EditorEventSource'
import QuickSettingsButton from './QuickSettingsButton'
import AccountMenuButton from './AccountMenuButton'
import StyledTooltip from '../StyledTooltip/StyledTooltip'
import UpgradeNow from './UpgradeNow'
type Props = {
application: WebApplication
@@ -362,14 +363,6 @@ class Footer extends PureComponent<Props, State> {
viewControllerManager={this.viewControllerManager}
/>
</div>
<div className="relative z-footer-bar-item select-none">
<QuickSettingsButton
isOpen={this.state.showQuickSettingsMenu}
toggleMenu={this.quickSettingsClickHandler}
application={this.application}
quickSettingsMenuController={this.viewControllerManager.quickSettingsMenuController}
/>
</div>
<div className="relative z-footer-bar-item select-none">
<StyledTooltip label="Open preferences">
<button
@@ -382,6 +375,18 @@ class Footer extends PureComponent<Props, State> {
</button>
</StyledTooltip>
</div>
<div className="relative z-footer-bar-item select-none">
<QuickSettingsButton
isOpen={this.state.showQuickSettingsMenu}
toggleMenu={this.quickSettingsClickHandler}
application={this.application}
quickSettingsMenuController={this.viewControllerManager.quickSettingsMenuController}
/>
</div>
<UpgradeNow
application={this.application}
featuresController={this.viewControllerManager.featuresController}
/>
{this.state.showBetaWarning && (
<Fragment>
<div className="relative z-footer-bar-item ml-3 flex select-none items-center border-l border-solid border-border pl-3">
@@ -0,0 +1,46 @@
import { WebApplication } from '@/Application/Application'
import { FeaturesController } from '@/Controllers/FeaturesController'
import { observer } from 'mobx-react-lite'
import { loadPurchaseFlowUrl } from '../PurchaseFlow/PurchaseFlowFunctions'
type Props = {
application: WebApplication
featuresController: FeaturesController
}
const UpgradeNow = ({ application, featuresController }: Props) => {
const shouldShowCTA = !featuresController.hasFolders
const hasAccount = application.hasAccount()
const openPlansPage = () => {
if (!window.plansUrl) {
return
}
if (application.isNativeMobileWeb()) {
application.mobileDevice.openUrl(window.plansUrl)
} else {
window.location.assign(window.plansUrl)
}
}
return shouldShowCTA ? (
<div className="flex h-full items-center px-2">
<button
className="rounded bg-info py-0.5 px-1.5 text-xs font-bold uppercase text-info-contrast hover:brightness-125"
onClick={() => {
if (hasAccount) {
void loadPurchaseFlowUrl(application)
return
}
openPlansPage()
}}
>
Upgrade now
</button>
</div>
) : null
}
export default observer(UpgradeNow)
@@ -86,6 +86,7 @@ export const ICONS = {
security: icons.SecurityIcon,
server: icons.ServerIcon,
settings: icons.SettingsIcon,
share: icons.ShareIcon,
signIn: icons.SignInIcon,
signOut: icons.SignOutIcon,
spreadsheets: icons.SpreadsheetsIcon,
@@ -99,6 +100,7 @@ export const ICONS = {
unarchive: icons.UnarchiveIcon,
unpin: icons.UnpinIcon,
user: icons.UserIcon,
view: icons.ViewIcon,
warning: icons.WarningIcon,
window: icons.WindowIcon,
}
@@ -12,6 +12,7 @@ import { classNames } from '@/Utils/ConcatenateClassNames'
import Icon from '../Icon/Icon'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
import { isIOS } from '@/Utils'
import UpgradeNow from '../Footer/UpgradeNow'
type Props = {
application: WebApplication
@@ -21,7 +22,7 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
const viewControllerManager = useMemo(() => application.getViewControllerManager(), [application])
const ref = useRef<HTMLDivElement>(null)
const [panelWidth, setPanelWidth] = useState<number>(0)
const { toggleAppPane } = useResponsiveAppPane()
const { selectedPane, toggleAppPane } = useResponsiveAppPane()
const [hasPasscode, setHasPasscode] = useState(() => application.hasPasscode())
useEffect(() => {
@@ -62,7 +63,11 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
<div
id="navigation"
className={classNames(
'sn-component section app-column h-screen max-h-screen w-[220px] overflow-hidden pt-safe-top md:h-full md:min-h-0 md:py-0 xsm-only:!w-full sm-only:!w-full',
'sn-component section app-column h-screen max-h-screen overflow-hidden pt-safe-top md:h-full md:max-h-full md:min-h-0 md:pb-0',
'w-[220px] xl:w-87.5 xsm-only:!w-full sm-only:!w-full',
selectedPane === AppPaneId.Navigation
? 'pointer-coarse:md-only:!w-48 pointer-coarse:lg-only:!w-48'
: 'pointer-coarse:md-only:!w-0 pointer-coarse:lg-only:!w-0',
isIOS() ? 'pb-safe-bottom' : 'pb-2.5',
)}
ref={ref}
@@ -70,7 +75,7 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
<ResponsivePaneContent paneId={AppPaneId.Navigation} contentElementId="navigation-content">
<div
className={classNames(
'flex-grow overflow-y-auto overflow-x-hidden md:overflow-y-hidden md:hover:overflow-y-auto',
'flex-grow overflow-y-auto overflow-x-hidden md:overflow-y-hidden md:hover:overflow-y-auto pointer-coarse:md:overflow-y-auto',
'md:hover:[overflow-y:_overlay]',
)}
>
@@ -86,7 +91,7 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
</div>
<div className="flex items-center border-t border-border px-3.5 pt-2.5 md:hidden">
<button
className="flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border bg-default text-neutral hover:bg-contrast focus:bg-contrast"
className="mr-auto flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border bg-default text-neutral hover:bg-contrast focus:bg-contrast"
onClick={() => {
toggleAppPane(AppPaneId.Items)
}}
@@ -95,8 +100,9 @@ const Navigation: FunctionComponent<Props> = ({ application }) => {
>
<Icon type="chevron-left" />
</button>
<UpgradeNow application={application} featuresController={viewControllerManager.featuresController} />
<button
className="ml-auto flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border bg-default text-neutral hover:bg-contrast focus:bg-contrast"
className="ml-2.5 flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border bg-default text-neutral hover:bg-contrast focus:bg-contrast"
onClick={() => {
viewControllerManager.accountMenuController.toggleShow()
}}
@@ -3,13 +3,17 @@ import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
export const NavigationMenuButton = () => {
const { toggleAppPane } = useResponsiveAppPane()
const { selectedPane, toggleAppPane } = useResponsiveAppPane()
return (
<button
className="bg-text-padding mr-3 inline-flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border align-middle text-neutral hover:bg-contrast focus:bg-contrast md:hidden"
className="bg-text-padding mr-3 inline-flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border align-middle text-neutral hover:bg-contrast focus:bg-contrast md:hidden pointer-coarse:md-only:inline-flex pointer-coarse:lg-only:inline-flex"
onClick={() => {
toggleAppPane(AppPaneId.Navigation)
if (selectedPane === AppPaneId.Items || selectedPane === AppPaneId.Editor) {
toggleAppPane(AppPaneId.Navigation)
} else {
toggleAppPane(AppPaneId.Items)
}
}}
title="Navigation menu"
aria-label="Navigation menu"
@@ -1,20 +1,36 @@
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import Icon from '../Icon/Icon'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
import { useMediaQuery, MediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
import { IconType } from '@standardnotes/snjs'
const MobileItemsListButton = () => {
const { toggleAppPane } = useResponsiveAppPane()
const { toggleAppPane, isNotesListVisibleOnTablets, toggleNotesListOnTablets } = useResponsiveAppPane()
const matchesMediumBreakpoint = useMediaQuery(MediaQueryBreakpoints.md)
const matchesXLBreakpoint = useMediaQuery(MediaQueryBreakpoints.xl)
const isTabletScreenSize = matchesMediumBreakpoint && !matchesXLBreakpoint
const iconType: IconType = isTabletScreenSize && !isNotesListVisibleOnTablets ? 'chevron-right' : 'chevron-left'
const label = isTabletScreenSize
? isNotesListVisibleOnTablets
? 'Hide items list'
: 'Show items list'
: 'Go to items list'
return (
<button
className="bg-text-padding mr-3 flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast md:hidden"
className="bg-text-padding mr-3 flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast md:hidden pointer-coarse:md-only:flex pointer-coarse:lg-only:flex"
onClick={() => {
toggleAppPane(AppPaneId.Items)
if (isTabletScreenSize) {
toggleNotesListOnTablets()
} else {
toggleAppPane(AppPaneId.Items)
}
}}
title="Go to items list"
aria-label="Go to items list"
title={label}
aria-label={label}
>
<Icon type="chevron-left" />
<Icon type={iconType} />
</button>
)
}
@@ -8,21 +8,23 @@ import {
useRef,
useState,
} from 'react'
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { SNTag } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
type Props = {
viewControllerManager: ViewControllerManager
noteTagsController: NoteTagsController
navigationController: NavigationController
tag: SNTag
}
const NoteTag = ({ viewControllerManager, tag }: Props) => {
const NoteTag = ({ noteTagsController, navigationController, tag }: Props) => {
const { toggleAppPane } = useResponsiveAppPane()
const noteTags = viewControllerManager.noteTagsController
const noteTags = noteTagsController
const { autocompleteInputFocused, focusedTagUuid, tags } = noteTags
@@ -37,9 +39,9 @@ const NoteTag = ({ viewControllerManager, tag }: Props) => {
const longTitle = noteTags.getLongTitle(tag)
const deleteTag = useCallback(() => {
viewControllerManager.noteTagsController.focusPreviousTag(tag)
viewControllerManager.noteTagsController.removeTagFromActiveNote(tag).catch(console.error)
}, [viewControllerManager, tag])
noteTagsController.focusPreviousTag(tag)
noteTagsController.removeTagFromActiveNote(tag).catch(console.error)
}, [noteTagsController, tag])
const onDeleteTagClick: MouseEventHandler = useCallback(
(event) => {
@@ -53,30 +55,30 @@ const NoteTag = ({ viewControllerManager, tag }: Props) => {
async (event) => {
if (tagClicked && event.target !== deleteTagRef.current) {
setTagClicked(false)
await viewControllerManager.navigationController.setSelectedTag(tag)
await navigationController.setSelectedTag(tag)
toggleAppPane(AppPaneId.Items)
} else {
setTagClicked(true)
tagRef.current?.focus()
}
},
[viewControllerManager, tagClicked, tag, toggleAppPane],
[tagClicked, navigationController, tag, toggleAppPane],
)
const onFocus = useCallback(() => {
viewControllerManager.noteTagsController.setFocusedTagUuid(tag.uuid)
noteTagsController.setFocusedTagUuid(tag.uuid)
setShowDeleteButton(true)
}, [viewControllerManager, tag])
}, [noteTagsController, tag])
const onBlur: FocusEventHandler = useCallback(
(event) => {
const relatedTarget = event.relatedTarget as Node
if (relatedTarget !== deleteTagRef.current) {
viewControllerManager.noteTagsController.setFocusedTagUuid(undefined)
noteTagsController.setFocusedTagUuid(undefined)
setShowDeleteButton(false)
}
},
[viewControllerManager],
[noteTagsController],
)
const getTabIndex = useCallback(() => {
@@ -91,33 +93,33 @@ const NoteTag = ({ viewControllerManager, tag }: Props) => {
const onKeyDown: KeyboardEventHandler = useCallback(
(event) => {
const tagIndex = viewControllerManager.noteTagsController.getTagIndex(tag, tags)
const tagIndex = noteTagsController.getTagIndex(tag, tags)
switch (event.key) {
case 'Backspace':
deleteTag()
break
case 'ArrowLeft':
viewControllerManager.noteTagsController.focusPreviousTag(tag)
noteTagsController.focusPreviousTag(tag)
break
case 'ArrowRight':
if (tagIndex === tags.length - 1) {
viewControllerManager.noteTagsController.setAutocompleteInputFocused(true)
noteTagsController.setAutocompleteInputFocused(true)
} else {
viewControllerManager.noteTagsController.focusNextTag(tag)
noteTagsController.focusNextTag(tag)
}
break
default:
return
}
},
[viewControllerManager, deleteTag, tag, tags],
[noteTagsController, deleteTag, tag, tags],
)
useEffect(() => {
if (focusedTagUuid === tag.uuid) {
tagRef.current?.focus()
}
}, [viewControllerManager, focusedTagUuid, tag])
}, [noteTagsController, focusedTagUuid, tag])
return (
<button
@@ -1,31 +1,33 @@
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { observer } from 'mobx-react-lite'
import AutocompleteTagInput from '@/Components/TagAutocomplete/AutocompleteTagInput'
import NoteTag from './NoteTag'
import { useEffect } from 'react'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { NavigationController } from '@/Controllers/Navigation/NavigationController'
type Props = {
viewControllerManager: ViewControllerManager
noteTagsController: NoteTagsController
navigationController: NavigationController
}
const NoteTagsContainer = ({ viewControllerManager }: Props) => {
const { tags, tagsContainerMaxWidth } = viewControllerManager.noteTagsController
const NoteTagsContainer = ({ noteTagsController, navigationController }: Props) => {
const { tags } = noteTagsController
useEffect(() => {
viewControllerManager.noteTagsController.reloadTagsContainerMaxWidth()
}, [viewControllerManager])
noteTagsController.reloadTagsContainerMaxWidth()
}, [noteTagsController])
return (
<div
className="flex min-w-80 flex-wrap bg-transparent md:-mr-2"
style={{
maxWidth: tagsContainerMaxWidth,
}}
>
<div className="hidden min-w-80 max-w-full flex-wrap bg-transparent md:-mr-2 md:flex">
{tags.map((tag) => (
<NoteTag key={tag.uuid} viewControllerManager={viewControllerManager} tag={tag} />
<NoteTag
key={tag.uuid}
noteTagsController={noteTagsController}
navigationController={navigationController}
tag={tag}
/>
))}
<AutocompleteTagInput viewControllerManager={viewControllerManager} />
<AutocompleteTagInput noteTagsController={noteTagsController} />
</div>
)
}
@@ -0,0 +1,170 @@
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { splitQueryInString } from '@/Utils'
import { SNTag } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { ChangeEventHandler, FormEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import Icon from '../Icon/Icon'
import Popover from '../Popover/Popover'
const ListItem = ({
tag,
isSearching,
noteTagsController,
autocompleteSearchQuery,
}: {
tag: SNTag
isSearching: boolean
noteTagsController: NoteTagsController
autocompleteSearchQuery: string
}) => {
const handleSearchResultClick = useCallback(async () => {
await noteTagsController.addTagToActiveNote(tag)
noteTagsController.clearAutocompleteSearch()
noteTagsController.setAutocompleteInputFocused(true)
}, [noteTagsController, tag])
const handleNoteTagRemove = useCallback(() => {
noteTagsController.removeTagFromActiveNote(tag).catch(console.error)
}, [noteTagsController, tag])
const longTitle = noteTagsController.getLongTitle(tag)
return isSearching ? (
<button
onClick={handleSearchResultClick}
className="max-w-80 flex w-full items-center border-0 bg-transparent px-3 py-2 text-left text-menu-item text-text hover:bg-info-backdrop focus:bg-info-backdrop"
>
{splitQueryInString(longTitle, autocompleteSearchQuery).map((substring, index) => (
<span
key={index}
className={
substring.toLowerCase() === autocompleteSearchQuery.toLowerCase()
? 'whitespace-pre-wrap font-bold'
: 'whitespace-pre-wrap'
}
>
{substring}
</span>
))}
</button>
) : (
<div className="max-w-80 flex w-full items-center justify-between border-0 bg-transparent px-3 py-2 text-left text-menu-item text-text">
<span className="overflow-hidden overflow-ellipsis whitespace-nowrap">{longTitle}</span>
<button
onClick={handleNoteTagRemove}
className="flex h-6 w-6 cursor-pointer items-center justify-center rounded-full text-danger hover:bg-info-backdrop focus:bg-info-backdrop"
>
<Icon type="trash" size="small" />
</button>
</div>
)
}
const NoteTagsPanel = ({
noteTagsController,
onClickPreprocessing,
}: {
noteTagsController: NoteTagsController
onClickPreprocessing?: () => Promise<void>
}) => {
const isDesktopScreen = useMediaQuery(MediaQueryBreakpoints.md)
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef<HTMLButtonElement>(null)
const { tags, autocompleteTagResults, autocompleteSearchQuery, autocompleteTagHintVisible } = noteTagsController
const isSearching = autocompleteSearchQuery.length > 0
const visibleTagsList = isSearching ? autocompleteTagResults : tags
const toggleMenu = useCallback(async () => {
const willMenuOpen = !isOpen
if (willMenuOpen && onClickPreprocessing) {
await onClickPreprocessing()
}
setIsOpen(willMenuOpen)
}, [onClickPreprocessing, isOpen])
const onSearchQueryChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const query = event.target.value
if (query === '') {
noteTagsController.clearAutocompleteSearch()
} else {
noteTagsController.setAutocompleteSearchQuery(query)
noteTagsController.searchActiveNoteAutocompleteTags()
}
}
const onFormSubmit: FormEventHandler = async (event) => {
event.preventDefault()
if (autocompleteSearchQuery !== '') {
await noteTagsController.createAndAddNewTag()
}
}
useEffect(() => {
if (isDesktopScreen) {
setIsOpen(false)
}
}, [isDesktopScreen])
return (
<>
<button
className="bg-text-padding flex h-8 min-w-8 cursor-pointer items-center justify-center rounded-full border border-solid border-border text-neutral hover:bg-contrast focus:bg-contrast md:hidden"
title="Note options menu"
aria-label="Note options menu"
onClick={toggleMenu}
ref={buttonRef}
>
<Icon type="hashtag" />
</button>
<Popover togglePopover={toggleMenu} anchorElement={buttonRef.current} open={isOpen} className="pb-2">
<form onSubmit={onFormSubmit} className="sticky top-0 border-b border-border bg-default px-2.5 py-2.5">
<input
type="text"
className="w-full rounded border border-solid border-border bg-default py-1.5 px-3 text-sm text-text"
placeholder="Create or search tag..."
value={autocompleteSearchQuery}
onChange={onSearchQueryChange}
ref={(node) => {
if (isOpen && node) {
node.focus()
}
}}
/>
</form>
<div className="pt-2.5">
{visibleTagsList.map((tag) => (
<ListItem
key={tag.uuid}
tag={tag}
isSearching={isSearching}
noteTagsController={noteTagsController}
autocompleteSearchQuery={autocompleteSearchQuery}
/>
))}
{autocompleteTagHintVisible && (
<button
onClick={async () => {
await noteTagsController.createAndAddNewTag()
}}
className="max-w-80 flex w-full items-center border-0 bg-transparent px-3 py-2 text-left text-menu-item text-text hover:bg-info-backdrop focus:bg-info-backdrop"
>
<span>Create new tag:</span>
<span className="ml-2 flex items-center rounded bg-contrast py-1 pl-1 pr-2 text-xs text-text">
<Icon type="hashtag" className="mr-1 text-neutral" size="small" />
<span className="max-w-40 overflow-hidden overflow-ellipsis whitespace-nowrap">
{autocompleteSearchQuery}
</span>
</span>
</button>
)}
</div>
</Popover>
</>
)
}
export default observer(NoteTagsPanel)
@@ -13,8 +13,9 @@ import {
NoteViewController,
PayloadEmitSource,
WebAppEvent,
Platform,
} from '@standardnotes/snjs'
import { debounce, isDesktopApplication } from '@/Utils'
import { debounce, isDesktopApplication, isIOS } from '@/Utils'
import { EditorEventSource } from '../../Types/EditorEventSource'
import { confirmDialog, KeyboardModifier, KeyboardKey } from '@standardnotes/ui-services'
import { STRING_DELETE_PLACEHOLDER_ATTEMPT, STRING_DELETE_LOCKED_ATTEMPT, StringDeleteNote } from '@/Constants/Strings'
@@ -22,7 +23,6 @@ import { PureComponent } from '@/Components/Abstract/PureComponent'
import ProtectedItemOverlay from '@/Components/ProtectedItemOverlay/ProtectedItemOverlay'
import PinNoteButton from '@/Components/PinNoteButton/PinNoteButton'
import NotesOptionsPanel from '@/Components/NotesOptions/NotesOptionsPanel'
import NoteTagsContainer from '@/Components/NoteTags/NoteTagsContainer'
import ComponentView from '@/Components/ComponentView/ComponentView'
import PanelResizer, { PanelSide, PanelResizeType } from '@/Components/PanelResizer/PanelResizer'
import { ElementIds } from '@/Constants/ElementIDs'
@@ -39,10 +39,13 @@ import IndicatorCircle from '../IndicatorCircle/IndicatorCircle'
import { classNames } from '@/Utils/ConcatenateClassNames'
import AutoresizingNoteViewTextarea from './AutoresizingTextarea'
import MobileItemsListButton from '../NoteGroupView/MobileItemsListButton'
import NoteTagsPanel from '../NoteTags/NoteTagsPanel'
import NoteTagsContainer from '../NoteTags/NoteTagsContainer'
const MINIMUM_STATUS_DURATION = 400
const TEXTAREA_DEBOUNCE = 100
const NOTE_EDITING_DISABLED_TEXT = 'Note editing disabled.'
const MinimumStatusDuration = 400
const TextareaDebounce = 100
const NoteEditingDisabledText = 'Note editing disabled.'
const StickyHeaderScrollThresholdInPx = 20
type NoteStatus = {
message?: string
@@ -80,6 +83,8 @@ type State = {
leftResizerOffset: number
rightResizerWidth: number
rightResizerOffset: number
shouldStickyHeader: boolean
}
class NoteView extends PureComponent<NoteViewProps, State> {
@@ -111,7 +116,9 @@ class NoteView extends PureComponent<NoteViewProps, State> {
this.debounceReloadEditorComponent = debounce(this.debounceReloadEditorComponent.bind(this), 25)
this.textAreaChangeDebounceSave = debounce(this.textAreaChangeDebounceSave, TEXTAREA_DEBOUNCE)
this.textAreaChangeDebounceSave = debounce(this.textAreaChangeDebounceSave, TextareaDebounce)
this.handleWindowScroll = debounce(this.handleWindowScroll, 10)
this.state = {
availableStackComponents: [],
@@ -119,7 +126,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
editorText: '',
editorTitle: '',
isDesktop: isDesktopApplication(),
lockText: NOTE_EDITING_DISABLED_TEXT,
lockText: NoteEditingDisabledText,
noteStatus: undefined,
noteLocked: this.controller.item.locked,
showLockedIcon: true,
@@ -132,12 +139,17 @@ class NoteView extends PureComponent<NoteViewProps, State> {
leftResizerOffset: 0,
rightResizerWidth: 0,
rightResizerOffset: 0,
shouldStickyHeader: false,
}
this.editorContentRef = createRef<HTMLDivElement>()
window.addEventListener('scroll', this.handleWindowScroll)
}
override deinit() {
window.removeEventListener('scroll', this.handleWindowScroll)
this.removeComponentStreamObserver?.()
;(this.removeComponentStreamObserver as unknown) = undefined
@@ -521,7 +533,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
this.setState({
noteStatus: status,
})
}, MINIMUM_STATUS_DURATION)
}, MinimumStatusDuration)
} else {
this.setState({
noteStatus: status,
@@ -871,6 +883,12 @@ class NoteView extends PureComponent<NoteViewProps, State> {
}
}
handleWindowScroll = () => {
this.setState({
shouldStickyHeader: window.scrollY > StickyHeaderScrollThresholdInPx,
})
}
override render() {
if (this.state.showProtectedWarning) {
return (
@@ -889,7 +907,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
<EditingDisabledBanner
onMouseLeave={() => {
this.setState({
lockText: NOTE_EDITING_DISABLED_TEXT,
lockText: NoteEditingDisabledText,
showLockedIcon: true,
})
}}
@@ -906,7 +924,18 @@ class NoteView extends PureComponent<NoteViewProps, State> {
)}
{this.note && (
<div id="editor-title-bar" className="content-title-bar section-title-bar z-editor-title-bar w-full">
<div
id="editor-title-bar"
className={classNames(
'content-title-bar section-title-bar z-editor-title-bar w-full bg-default',
this.state.shouldStickyHeader && 'fixed top-0',
this.state.shouldStickyHeader
? isIOS() || this.application.platform === Platform.Ios
? 'pt-safe-top'
: 'pt-4'
: '',
)}
>
<div className="mb-2 flex flex-wrap items-start justify-between gap-2 md:mb-0 md:flex-nowrap md:gap-0 xl:items-center">
<div className={classNames(this.state.noteLocked && 'locked', 'flex flex-grow items-center')}>
<MobileItemsListButton />
@@ -926,55 +955,68 @@ class NoteView extends PureComponent<NoteViewProps, State> {
/>
</div>
</div>
<div className="flex flex-row-reverse items-center gap-3 md:flex-col-reverse md:items-end xl:flex-row xl:flex-nowrap xl:items-center">
{this.state.noteStatus?.message?.length && (
<div id="save-status-container" className={'xl:mr-5 xl:max-w-[16ch]'}>
<div id="save-status">
<div
className={
(this.state.syncTakingTooLong ? 'font-bold text-warning ' : '') +
(this.state.saveError ? 'font-bold text-danger ' : '') +
'message text-xs'
}
>
{this.state.noteStatus?.message}
{!this.state.shouldStickyHeader && (
<div className="flex flex-row-reverse items-center gap-3 md:flex-col-reverse md:items-end xl:flex-row xl:flex-nowrap xl:items-center">
{this.state.noteStatus?.message?.length && (
<div id="save-status-container" className={'xl:mr-5 xl:max-w-[16ch]'}>
<div id="save-status">
<div
className={
(this.state.syncTakingTooLong ? 'font-bold text-warning ' : '') +
(this.state.saveError ? 'font-bold text-danger ' : '') +
'message text-xs'
}
>
{this.state.noteStatus?.message}
</div>
{this.state.noteStatus?.desc && (
<div className="desc text-xs">{this.state.noteStatus.desc}</div>
)}
</div>
{this.state.noteStatus?.desc && <div className="desc text-xs">{this.state.noteStatus.desc}</div>}
</div>
)}
<div className="flex items-center gap-3">
<NoteTagsPanel
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
noteTagsController={this.viewControllerManager.noteTagsController}
/>
<AttachedFilesButton
application={this.application}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
featuresController={this.viewControllerManager.featuresController}
filePreviewModalController={this.viewControllerManager.filePreviewModalController}
filesController={this.viewControllerManager.filesController}
navigationController={this.viewControllerManager.navigationController}
notesController={this.viewControllerManager.notesController}
selectionController={this.viewControllerManager.selectionController}
/>
<ChangeEditorButton
application={this.application}
viewControllerManager={this.viewControllerManager}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/>
<PinNoteButton
notesController={this.viewControllerManager.notesController}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/>
<NotesOptionsPanel
application={this.application}
navigationController={this.viewControllerManager.navigationController}
notesController={this.viewControllerManager.notesController}
noteTagsController={this.viewControllerManager.noteTagsController}
historyModalController={this.viewControllerManager.historyModalController}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/>
</div>
)}
<div className="flex items-center gap-3">
<AttachedFilesButton
application={this.application}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
featuresController={this.viewControllerManager.featuresController}
filePreviewModalController={this.viewControllerManager.filePreviewModalController}
filesController={this.viewControllerManager.filesController}
navigationController={this.viewControllerManager.navigationController}
notesController={this.viewControllerManager.notesController}
selectionController={this.viewControllerManager.selectionController}
/>
<ChangeEditorButton
application={this.application}
viewControllerManager={this.viewControllerManager}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/>
<PinNoteButton
notesController={this.viewControllerManager.notesController}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/>
<NotesOptionsPanel
application={this.application}
navigationController={this.viewControllerManager.navigationController}
notesController={this.viewControllerManager.notesController}
noteTagsController={this.viewControllerManager.noteTagsController}
historyModalController={this.viewControllerManager.historyModalController}
onClickPreprocessing={this.ensureNoteIsInsertedBeforeUIAction}
/>
</div>
</div>
)}
</div>
<NoteTagsContainer viewControllerManager={this.viewControllerManager} />
{!this.state.shouldStickyHeader && (
<NoteTagsContainer
noteTagsController={this.viewControllerManager.noteTagsController}
navigationController={this.viewControllerManager.navigationController}
/>
)}
</div>
)}
@@ -41,7 +41,7 @@ const NotesContextMenu = ({
side="right"
togglePopover={closeMenu}
>
<div ref={contextMenuRef}>
<div className="select-none" ref={contextMenuRef}>
<NotesOptions
application={application}
navigationController={navigationController}
@@ -2,7 +2,7 @@ import Icon from '@/Components/Icon/Icon'
import Switch from '@/Components/Switch/Switch'
import { observer } from 'mobx-react-lite'
import { useState, useEffect, useMemo, useCallback, FunctionComponent } from 'react'
import { SNApplication, SNComponent, SNNote } from '@standardnotes/snjs'
import { Platform, SNApplication, SNComponent, SNNote } from '@standardnotes/snjs'
import { KeyboardModifier } from '@standardnotes/ui-services'
import ChangeEditorOption from './ChangeEditorOption'
import { BYTES_IN_ONE_MEGABYTE } from '@/Constants/Constants'
@@ -15,6 +15,9 @@ import HorizontalSeparator from '../Shared/HorizontalSeparator'
import { formatDateForContextMenu } from '@/Utils/DateUtils'
import { useResponsiveAppPane } from '../ResponsivePane/ResponsivePaneProvider'
import { AppPaneId } from '../ResponsivePane/AppPaneMetadata'
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
import { shareSelectedItems } from '@/NativeMobileWeb/ShareSelectedItems'
import { downloadSelectedItemsOnAndroid } from '@/NativeMobileWeb/DownloadSelectedItemsOnAndroid'
type DeletePermanentlyButtonProps = {
onClick: () => void
@@ -95,7 +98,7 @@ const NoteAttributes: FunctionComponent<{
const format = editor?.package_info?.file_type || 'txt'
return (
<div className="px-3 py-1.5 text-xs font-medium text-neutral">
<div className="select-text px-3 py-1.5 text-xs font-medium text-neutral">
{typeof words === 'number' && (format === 'txt' || format === 'md') ? (
<>
<div className="mb-1">
@@ -220,18 +223,11 @@ const NotesOptions = ({
}
}, [application])
const getNoteFileName = useCallback(
(note: SNNote): string => {
const editor = application.componentManager.editorForNote(note)
const format = editor?.package_info?.file_type || 'txt'
return `${note.title}.${format}`
},
[application.componentManager],
)
const downloadSelectedItems = useCallback(async () => {
if (notes.length === 1) {
application.getArchiveService().downloadData(new Blob([notes[0].text]), getNoteFileName(notes[0]))
const note = notes[0]
const blob = getNoteBlob(application, note)
application.getArchiveService().downloadData(blob, getNoteFileName(application, note))
return
}
@@ -243,8 +239,8 @@ const NotesOptions = ({
await application.getArchiveService().downloadDataAsZip(
notes.map((note) => {
return {
name: getNoteFileName(note),
content: new Blob([note.text]),
name: getNoteFileName(application, note),
content: getNoteBlob(application, note),
}
}),
)
@@ -254,7 +250,7 @@ const NotesOptions = ({
message: `Exported ${notes.length} notes`,
})
}
}, [application, getNoteFileName, notes])
}, [application, notes])
const closeMenuAndToggleNotesList = useCallback(() => {
toggleAppPane(AppPaneId.Items)
@@ -358,11 +354,22 @@ const NotesOptions = ({
)}
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={downloadSelectedItems}
onClick={() => {
application.isNativeMobileWeb() ? shareSelectedItems(application, notes) : downloadSelectedItems()
}}
>
<Icon type="download" className={iconClass} />
Export
<Icon type={application.platform === Platform.Android ? 'share' : 'download'} className={iconClass} />
{application.platform === Platform.Android ? 'Share' : 'Export'}
</button>
{application.platform === Platform.Android && (
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={() => downloadSelectedItemsOnAndroid(application, notes)}
>
<Icon type="download" className={iconClass} />
Export
</button>
)}
<button
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-menu-item text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
onClick={duplicateSelectedItems}
@@ -48,7 +48,7 @@ const NotesOptionsPanel = ({
>
<Icon type="more" />
</button>
<Popover togglePopover={toggleMenu} anchorElement={buttonRef.current} open={isOpen} className="py-2">
<Popover togglePopover={toggleMenu} anchorElement={buttonRef.current} open={isOpen} className="select-none py-2">
<NotesOptions
application={application}
navigationController={navigationController}
@@ -1,3 +1,4 @@
import { MediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
import { CSSProperties } from 'react'
import { PopoverAlignment, PopoverSide } from './Types'
import { OppositeSide, checkCollisions, getNonCollidingSide, getNonCollidingAlignment } from './Utils/Collisions'
@@ -29,7 +30,7 @@ export const getPositionedPopoverStyles = ({
return [null, side, align]
}
const matchesMediumBreakpoint = matchMedia('(min-width: 768px)').matches
const matchesMediumBreakpoint = matchMedia(MediaQueryBreakpoints.md).matches
if (!matchesMediumBreakpoint) {
return [null, side, align]
@@ -1,3 +1,4 @@
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
import { UuidGenerator } from '@standardnotes/snjs'
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
import PositionedPopoverContent from './PositionedPopoverContent'
@@ -41,6 +42,8 @@ const Popover = ({
}: Props) => {
const popoverId = useRef(UuidGenerator.GenerateUuid())
const addAndroidBackHandler = useAndroidBackHandler()
useRegisterPopoverToParent(popoverId.current)
const [childPopovers, setChildPopovers] = useState<Set<string>>(new Set())
@@ -64,6 +67,23 @@ const Popover = ({
[registerChildPopover, unregisterChildPopover],
)
useEffect(() => {
let removeListener: (() => void) | undefined
if (open) {
removeListener = addAndroidBackHandler(() => {
togglePopover()
return true
})
}
return () => {
if (removeListener) {
removeListener()
}
}
}, [addAndroidBackHandler, open, togglePopover])
return open ? (
<PopoverContext.Provider value={contextValue}>
<PositionedPopoverContent
@@ -9,8 +9,8 @@ import { getPositionedPopoverStyles } from './GetPositionedPopoverStyles'
import { PopoverContentProps } from './Types'
import { getPopoverMaxHeight, getAppRect } from './Utils/Rect'
import { usePopoverCloseOnClickOutside } from './Utils/usePopoverCloseOnClickOutside'
import { fitNodeToMobileScreen } from '@/Utils'
import { useDisableBodyScrollOnMobile } from '@/Hooks/useDisableBodyScrollOnMobile'
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
const PositionedPopoverContent = ({
align = 'end',
@@ -35,6 +35,7 @@ const PositionedPopoverContent = ({
})
const anchorRect = anchorPoint ? anchorPointRect : anchorElementRect
const documentRect = useDocumentRect()
const isDesktopScreen = useMediaQuery(MediaQueryBreakpoints.md)
const [styles, positionedSide, positionedAlignment] = getPositionedPopoverStyles({
align,
@@ -57,30 +58,29 @@ const PositionedPopoverContent = ({
<Portal>
<div
className={classNames(
'safe-area-padding absolute top-0 left-0 flex h-full w-full min-w-80 cursor-auto flex-col overflow-y-auto rounded bg-default shadow-main md:h-auto md:max-w-xs',
'absolute top-0 left-0 flex h-full w-full min-w-80 cursor-auto flex-col overflow-y-auto rounded bg-default shadow-main md:h-auto md:max-w-xs',
overrideZIndex ? overrideZIndex : 'z-dropdown-menu',
!isDesktopScreen ? 'pt-safe-top pb-safe-bottom' : '',
)}
style={{
...styles,
maxHeight: getPopoverMaxHeight(getAppRect(documentRect), anchorRect, positionedSide, positionedAlignment),
top: !isDesktopScreen ? `${document.documentElement.scrollTop}px` : '',
}}
ref={(node) => {
setPopoverElement(node)
fitNodeToMobileScreen(node)
}}
data-popover={id}
>
<div className={className}>
<div className="md:hidden">
<div className="flex items-center justify-end px-3">
<button className="rounded-full border border-border p-1" onClick={togglePopover}>
<Icon type="close" className="h-4 w-4" />
</button>
</div>
<HorizontalSeparator classes="my-2" />
<div className="md:hidden">
<div className="flex items-center justify-end px-3 pt-2">
<button className="rounded-full border border-border p-1" onClick={togglePopover}>
<Icon type="close" className="h-4 w-4" />
</button>
</div>
{children}
<HorizontalSeparator classes="my-2" />
</div>
<div className={className}>{children}</div>
</div>
</Portal>
)
@@ -1,3 +1,4 @@
import { MediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
import { PopoverSide, PopoverAlignment } from '../Types'
export const getPopoverMaxHeight = (
@@ -6,7 +7,7 @@ export const getPopoverMaxHeight = (
side: PopoverSide,
alignment: PopoverAlignment,
): number | 'none' => {
const matchesMediumBreakpoint = matchMedia('(min-width: 768px)').matches
const matchesMediumBreakpoint = matchMedia(MediaQueryBreakpoints.md).matches
if (!matchesMediumBreakpoint) {
return 'none'
@@ -1,3 +1,4 @@
import { MediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
import { useEffect } from 'react'
type Options = {
@@ -15,7 +16,7 @@ export const usePopoverCloseOnClickOutside = ({
}: Options) => {
useEffect(() => {
const closeIfClickedOutside = (event: MouseEvent) => {
const matchesMediumBreakpoint = matchMedia('(min-width: 768px)').matches
const matchesMediumBreakpoint = matchMedia(MediaQueryBreakpoints.md).matches
if (!matchesMediumBreakpoint) {
return
@@ -10,6 +10,7 @@ import FilesSection from './Files'
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
import SubscriptionSharing from './SubscriptionSharing/SubscriptionSharing'
import Email from './Email'
import DeleteAccount from '@/Components/Preferences/Panes/Account/DeleteAccount'
type Props = {
application: WebApplication
@@ -33,6 +34,7 @@ const AccountPreferences = ({ application, viewControllerManager }: Props) => (
)}
<Email application={application} />
<SignOutWrapper application={application} viewControllerManager={viewControllerManager} />
<DeleteAccount application={application} viewControllerManager={viewControllerManager} />
</PreferencesPane>
)
@@ -0,0 +1,42 @@
import { observer } from 'mobx-react-lite'
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
import { Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import Button from '@/Components/Button/Button'
import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup'
import { ViewControllerManager } from '@Controllers/ViewControllerManager'
import { WebApplication } from '@/Application/Application'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
}
const DeleteAccount = ({ application, viewControllerManager }: Props) => {
if (!application.hasAccount()) {
return null
}
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Delete account</Title>
<Text>
This action is irreversible. After deletion completes, you will be signed out on all devices. If you have an
active paid subscription, cancel the subscription first. Otherwise, if you'd like to keep the subscription,
you can re-register with the same email after deletion, and your subscription will be linked back up with your
account.
</Text>
<div className="mt-3 flex flex-row flex-wrap gap-3">
<Button
colorStyle="danger"
label="Delete my account"
onClick={() => {
viewControllerManager.accountMenuController.setDeletingAccount(true)
}}
/>
</div>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default observer(DeleteAccount)
@@ -26,7 +26,7 @@ const SubscriptionSharing: FunctionComponent<Props> = ({ application, viewContro
const subscriptionState = viewControllerManager.subscriptionController
const isSubscriptionSharingFeatureAvailable =
application.features.getFeatureStatus(FeatureIdentifier.TwoFactorAuth) === FeatureStatus.Entitled
application.features.getFeatureStatus(FeatureIdentifier.SubscriptionSharing) === FeatureStatus.Entitled
return (
<PreferencesGroup>
@@ -4,52 +4,85 @@ import { observer } from 'mobx-react-lite'
import { PreferencesMenu } from './PreferencesMenu'
import PreferencesCanvas from './PreferencesCanvas'
import { PreferencesProps } from './PreferencesProps'
import { fitNodeToMobileScreen, isIOS } from '@/Utils'
import { isIOS } from '@/Utils'
import { useDisableBodyScrollOnMobile } from '@/Hooks/useDisableBodyScrollOnMobile'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { MediaQueryBreakpoints, useMediaQuery } from '@/Hooks/useMediaQuery'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
const PreferencesView: FunctionComponent<PreferencesProps> = ({
application,
viewControllerManager,
closePreferences,
userProvider,
mfaProvider,
}) => {
const isDesktopScreen = useMediaQuery(MediaQueryBreakpoints.md)
const PreferencesView: FunctionComponent<PreferencesProps> = (props) => {
const menu = useMemo(
() => new PreferencesMenu(props.application, props.viewControllerManager.enableUnfinishedFeatures),
[props.viewControllerManager.enableUnfinishedFeatures, props.application],
() => new PreferencesMenu(application, viewControllerManager.enableUnfinishedFeatures),
[viewControllerManager.enableUnfinishedFeatures, application],
)
useEffect(() => {
menu.selectPane(props.viewControllerManager.preferencesController.currentPane)
const removeEscKeyObserver = props.application.io.addKeyObserver({
menu.selectPane(viewControllerManager.preferencesController.currentPane)
const removeEscKeyObserver = application.io.addKeyObserver({
key: 'Escape',
onKeyDown: (event) => {
event.preventDefault()
props.closePreferences()
closePreferences()
},
})
return () => {
removeEscKeyObserver()
}
}, [props, menu])
}, [menu, viewControllerManager.preferencesController.currentPane, application.io, closePreferences])
useDisableBodyScrollOnMobile()
const addAndroidBackHandler = useAndroidBackHandler()
useEffect(() => {
const removeListener = addAndroidBackHandler(() => {
closePreferences()
return true
})
return () => {
if (removeListener) {
removeListener()
}
}
}, [addAndroidBackHandler, closePreferences])
return (
<div
className={classNames(
'absolute top-0 left-0 z-preferences flex h-full max-h-screen w-full flex-col bg-default pt-safe-top',
'absolute top-0 left-0 z-preferences flex h-screen max-h-screen w-full flex-col bg-default pt-safe-top md:h-full md:max-h-full',
isIOS() ? 'pb-safe-bottom' : 'pb-2 md:pb-0',
)}
ref={fitNodeToMobileScreen}
style={{
top: !isDesktopScreen ? `${document.documentElement.scrollTop}px` : '',
}}
>
<div className="flex w-full flex-row items-center justify-between border-b border-solid border-border bg-default px-3 py-2 md:p-3">
<div className="hidden h-8 w-8 md:block" />
<h1 className="text-base font-bold md:text-lg">Your preferences for Standard Notes</h1>
<RoundIconButton
onClick={() => {
props.closePreferences()
closePreferences()
}}
type="normal"
icon="close"
/>
</div>
<PreferencesCanvas {...props} menu={menu} />
<PreferencesCanvas
menu={menu}
application={application}
viewControllerManager={viewControllerManager}
closePreferences={closePreferences}
userProvider={userProvider}
mfaProvider={mfaProvider}
/>
</div>
)
}
@@ -20,7 +20,14 @@ export const loadPurchaseFlowUrl = async (application: WebApplication): Promise<
const period = params.get('period') ? `&period=${params.get('period')}` : ''
const plan = params.get('plan') ? `&plan=${params.get('plan')}` : ''
if (url) {
window.location.assign(`${url}${period}${plan}`)
const finalUrl = `${url}${period}${plan}`
if (application.isNativeMobileWeb()) {
application.mobileDevice.openUrl(finalUrl)
} else {
window.location.assign(finalUrl)
}
return true
}
return false
@@ -0,0 +1,78 @@
import { WebApplication } from '@/Application/Application'
import { memo, useEffect, useState } from 'react'
import { ApplicationEvent, PrefKey } from '@standardnotes/snjs'
import MenuItem from '../Menu/MenuItem'
import { MenuItemType } from '../Menu/MenuItemType'
import { PANEL_NAME_NAVIGATION, PANEL_NAME_NOTES } from '@/Constants/Constants'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
type Props = {
application: WebApplication
}
const WidthForCollapsedPanel = 5
const MinimumNavPanelWidth = 220
const MinimumNotesPanelWidth = 350
const PanelSettingsSection = ({ application }: Props) => {
const [currentNavPanelWidth, setCurrentNavPanelWidth] = useState(
application.getPreference(PrefKey.TagsPanelWidth, MinimumNavPanelWidth),
)
const [currentItemsPanelWidth, setCurrentItemsPanelWidth] = useState(
application.getPreference(PrefKey.NotesPanelWidth, MinimumNotesPanelWidth),
)
const toggleNavigationPanel = () => {
const isCollapsed = currentNavPanelWidth <= WidthForCollapsedPanel
if (isCollapsed) {
void application.setPreference(PrefKey.TagsPanelWidth, MinimumNavPanelWidth)
} else {
void application.setPreference(PrefKey.TagsPanelWidth, WidthForCollapsedPanel)
}
application.publishPanelDidResizeEvent(PANEL_NAME_NAVIGATION, !isCollapsed)
}
const toggleItemsListPanel = () => {
const isCollapsed = currentItemsPanelWidth <= WidthForCollapsedPanel
if (isCollapsed) {
void application.setPreference(PrefKey.NotesPanelWidth, MinimumNotesPanelWidth)
} else {
void application.setPreference(PrefKey.NotesPanelWidth, WidthForCollapsedPanel)
}
application.publishPanelDidResizeEvent(PANEL_NAME_NOTES, !isCollapsed)
}
useEffect(() => {
const removeObserver = application.addEventObserver(async () => {
setCurrentNavPanelWidth(application.getPreference(PrefKey.TagsPanelWidth, MinimumNavPanelWidth))
setCurrentItemsPanelWidth(application.getPreference(PrefKey.NotesPanelWidth, MinimumNotesPanelWidth))
}, ApplicationEvent.PreferencesChanged)
return removeObserver
}, [application])
return (
<div className="hidden text-sm md:block pointer-coarse:md-only:hidden pointer-coarse:lg-only:hidden">
<HorizontalSeparator classes="my-2" />
<div className="my-1 px-3 text-sm font-semibold uppercase text-text">Panel Settings</div>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={currentNavPanelWidth > WidthForCollapsedPanel}
onChange={toggleNavigationPanel}
>
Show navigation panel
</MenuItem>
<MenuItem
type={MenuItemType.SwitchButton}
className="py-1 hover:bg-contrast focus:bg-info-backdrop"
checked={currentItemsPanelWidth > WidthForCollapsedPanel}
onChange={toggleItemsListPanel}
>
Show items list panel
</MenuItem>
</div>
)
}
export default memo(PanelSettingsSection)
@@ -11,6 +11,7 @@ import { sortThemes } from '@/Utils/SortThemes'
import RadioIndicator from '../RadioIndicator/RadioIndicator'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
import { QuickSettingsController } from '@/Controllers/QuickSettingsController'
import PanelSettingsSection from './PanelSettingsSection'
const focusModeAnimationDuration = 1255
@@ -174,6 +175,7 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = ({ application, quickSet
onClose={closeQuickSettingsMenu}
isEnabled={focusModeEnabled}
/>
<PanelSettingsSection application={application} />
</div>
)
}
@@ -1,11 +1,26 @@
import { ElementIds } from '@/Constants/ElementIDs'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
import { isMobileScreen } from '@/Utils'
import { useEffect, ReactNode, useMemo, createContext, useCallback, useContext, useState, memo } from 'react'
import {
useEffect,
ReactNode,
useMemo,
createContext,
useCallback,
useContext,
useState,
memo,
useRef,
useLayoutEffect,
MutableRefObject,
} from 'react'
import { AppPaneId } from './AppPaneMetadata'
type ResponsivePaneData = {
selectedPane: AppPaneId
toggleAppPane: (paneId: AppPaneId) => void
isNotesListVisibleOnTablets: boolean
toggleNotesListOnTablets: () => void
}
const ResponsivePaneContext = createContext<ResponsivePaneData | undefined>(undefined)
@@ -20,31 +35,37 @@ export const useResponsiveAppPane = () => {
return value
}
type Props = {
type ChildrenProps = {
children: ReactNode
}
const MemoizedChildren = memo(({ children }: Props) => <div>{children}</div>)
function useStateRef<State>(state: State): MutableRefObject<State> {
const ref = useRef<State>(state)
const ResponsivePaneProvider = ({ children }: Props) => {
useLayoutEffect(() => {
ref.current = state
}, [state])
return ref
}
const MemoizedChildren = memo(({ children }: ChildrenProps) => <div>{children}</div>)
const ResponsivePaneProvider = ({ children }: ChildrenProps) => {
const [currentSelectedPane, setCurrentSelectedPane] = useState<AppPaneId>(
isMobileScreen() ? AppPaneId.Items : AppPaneId.Editor,
)
const currentSelectedPaneRef = useStateRef<AppPaneId>(currentSelectedPane)
const [previousSelectedPane, setPreviousSelectedPane] = useState<AppPaneId>(
isMobileScreen() ? AppPaneId.Items : AppPaneId.Editor,
)
const toggleAppPane = useCallback(
(paneId: AppPaneId) => {
if (paneId === currentSelectedPane) {
setCurrentSelectedPane(previousSelectedPane ? previousSelectedPane : AppPaneId.Editor)
setPreviousSelectedPane(paneId)
} else {
setPreviousSelectedPane(currentSelectedPane)
setCurrentSelectedPane(paneId)
}
setPreviousSelectedPane(currentSelectedPane)
setCurrentSelectedPane(paneId)
},
[currentSelectedPane, previousSelectedPane],
[currentSelectedPane],
)
useEffect(() => {
@@ -57,12 +78,41 @@ const ResponsivePaneProvider = ({ children }: Props) => {
currentPaneElement?.classList.add('selected')
}, [currentSelectedPane, previousSelectedPane])
const addAndroidBackHandler = useAndroidBackHandler()
useEffect(() => {
const removeListener = addAndroidBackHandler(() => {
if (
currentSelectedPaneRef.current === AppPaneId.Editor ||
currentSelectedPaneRef.current === AppPaneId.Navigation
) {
toggleAppPane(AppPaneId.Items)
return true
} else {
return false
}
})
return () => {
if (removeListener) {
removeListener()
}
}
}, [addAndroidBackHandler, currentSelectedPaneRef, toggleAppPane])
const [isNotesListVisibleOnTablets, setNotesListVisibleOnTablets] = useState(true)
const toggleNotesListOnTablets = useCallback(() => {
setNotesListVisibleOnTablets((visible) => !visible)
}, [])
const contextValue = useMemo(
() => ({
selectedPane: currentSelectedPane,
toggleAppPane,
isNotesListVisibleOnTablets,
toggleNotesListOnTablets,
}),
[currentSelectedPane, toggleAppPane],
[currentSelectedPane, isNotesListVisibleOnTablets, toggleAppPane, toggleNotesListOnTablets],
)
return (
@@ -1,8 +1,9 @@
import { observer } from 'mobx-react-lite'
import { FunctionComponent } from 'react'
import { FunctionComponent, useEffect } from 'react'
import HistoryModalDialogContent from './HistoryModalDialogContent'
import HistoryModalDialog from './HistoryModalDialog'
import { RevisionHistoryModalProps } from './RevisionHistoryModalProps'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
const RevisionHistoryModal: FunctionComponent<RevisionHistoryModalProps> = ({
application,
@@ -11,6 +12,27 @@ const RevisionHistoryModal: FunctionComponent<RevisionHistoryModalProps> = ({
selectionController,
subscriptionController,
}) => {
const addAndroidBackHandler = useAndroidBackHandler()
const isOpen = !!historyModalController.note
useEffect(() => {
let removeListener: (() => void) | undefined
if (isOpen) {
removeListener = addAndroidBackHandler(() => {
historyModalController.dismissModal()
return true
})
}
return () => {
if (removeListener) {
removeListener()
}
}
}, [addAndroidBackHandler, historyModalController, isOpen])
if (!historyModalController.note) {
return null
}
@@ -1,7 +1,8 @@
import { FunctionComponent, ReactNode } from 'react'
import { FunctionComponent, ReactNode, useEffect } from 'react'
import { AlertDialogLabel } from '@reach/alert-dialog'
import Icon from '@/Components/Icon/Icon'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
type Props = {
closeDialog: () => void
@@ -10,24 +11,40 @@ type Props = {
children?: ReactNode
}
const ModalDialogLabel: FunctionComponent<Props> = ({ children, closeDialog, className, headerButtons }) => (
<AlertDialogLabel
className={classNames(
'flex flex-shrink-0 items-center justify-between rounded-t border-b border-solid border-border bg-default px-4.5 py-3 text-text',
className,
)}
>
<div className="flex w-full flex-row items-center justify-between">
<div className="flex-grow text-lg font-semibold text-text">{children}</div>
<div className="flex items-center gap-2">
{headerButtons}
<button tabIndex={0} className="rounded p-1 font-bold hover:bg-contrast" onClick={closeDialog}>
<Icon type="close" />
</button>
const ModalDialogLabel: FunctionComponent<Props> = ({ children, closeDialog, className, headerButtons }) => {
const addAndroidBackHandler = useAndroidBackHandler()
useEffect(() => {
const removeListener = addAndroidBackHandler(() => {
closeDialog()
return true
})
return () => {
if (removeListener) {
removeListener()
}
}
}, [addAndroidBackHandler, closeDialog])
return (
<AlertDialogLabel
className={classNames(
'flex flex-shrink-0 items-center justify-between rounded-t border-b border-solid border-border bg-default px-4.5 py-3 text-text',
className,
)}
>
<div className="flex w-full flex-row items-center justify-between">
<div className="flex-grow text-lg font-semibold text-text">{children}</div>
<div className="flex items-center gap-2">
{headerButtons}
<button tabIndex={0} className="rounded p-1 font-bold hover:bg-contrast" onClick={closeDialog}>
<Icon type="close" />
</button>
</div>
</div>
</div>
<hr className="h-1px no-border m-0 bg-border" />
</AlertDialogLabel>
)
<hr className="h-1px no-border m-0 bg-border" />
</AlertDialogLabel>
)
}
export default ModalDialogLabel
@@ -9,6 +9,5 @@ export default styled(Tooltip)`
background-color: var(--sn-stylekit-contrast-background-color);
color: var(--sn-stylekit-foreground-color);
border-color: var(--sn-stylekit-border-color);
z-index: var(--z-index-tooltip);
}
`
@@ -1,36 +1,36 @@
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
import { observer } from 'mobx-react-lite'
import { useRef, useEffect, useCallback, FocusEventHandler, KeyboardEventHandler } from 'react'
import Icon from '@/Components/Icon/Icon'
import HorizontalSeparator from '../Shared/HorizontalSeparator'
type Props = {
viewControllerManager: ViewControllerManager
noteTagsController: NoteTagsController
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}
const AutocompleteTagHint = ({ viewControllerManager, closeOnBlur }: Props) => {
const { autocompleteTagHintFocused } = viewControllerManager.noteTagsController
const AutocompleteTagHint = ({ noteTagsController, closeOnBlur }: Props) => {
const { autocompleteTagHintFocused } = noteTagsController
const hintRef = useRef<HTMLButtonElement>(null)
const { autocompleteSearchQuery, autocompleteTagResults } = viewControllerManager.noteTagsController
const { autocompleteSearchQuery, autocompleteTagResults } = noteTagsController
const onTagHintClick = useCallback(async () => {
await viewControllerManager.noteTagsController.createAndAddNewTag()
viewControllerManager.noteTagsController.setAutocompleteInputFocused(true)
}, [viewControllerManager])
await noteTagsController.createAndAddNewTag()
noteTagsController.setAutocompleteInputFocused(true)
}, [noteTagsController])
const onFocus = useCallback(() => {
viewControllerManager.noteTagsController.setAutocompleteTagHintFocused(true)
}, [viewControllerManager])
noteTagsController.setAutocompleteTagHintFocused(true)
}, [noteTagsController])
const onBlur: FocusEventHandler = useCallback(
(event) => {
closeOnBlur(event)
viewControllerManager.noteTagsController.setAutocompleteTagHintFocused(false)
noteTagsController.setAutocompleteTagHintFocused(false)
},
[viewControllerManager, closeOnBlur],
[noteTagsController, closeOnBlur],
)
const onKeyDown: KeyboardEventHandler = useCallback(
@@ -38,20 +38,20 @@ const AutocompleteTagHint = ({ viewControllerManager, closeOnBlur }: Props) => {
if (event.key === 'ArrowUp') {
if (autocompleteTagResults.length > 0) {
const lastTagResult = autocompleteTagResults[autocompleteTagResults.length - 1]
viewControllerManager.noteTagsController.setFocusedTagResultUuid(lastTagResult.uuid)
noteTagsController.setFocusedTagResultUuid(lastTagResult.uuid)
} else {
viewControllerManager.noteTagsController.setAutocompleteInputFocused(true)
noteTagsController.setAutocompleteInputFocused(true)
}
}
},
[viewControllerManager, autocompleteTagResults],
[noteTagsController, autocompleteTagResults],
)
useEffect(() => {
if (autocompleteTagHintFocused) {
hintRef.current?.focus()
}
}, [viewControllerManager, autocompleteTagHintFocused])
}, [noteTagsController, autocompleteTagHintFocused])
return (
<>
@@ -9,19 +9,19 @@ import {
} from 'react'
import { Disclosure, DisclosurePanel } from '@reach/disclosure'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import AutocompleteTagResult from './AutocompleteTagResult'
import AutocompleteTagHint from './AutocompleteTagHint'
import { observer } from 'mobx-react-lite'
import { SNTag } from '@standardnotes/snjs'
import { classNames } from '@/Utils/ConcatenateClassNames'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
type Props = {
viewControllerManager: ViewControllerManager
noteTagsController: NoteTagsController
}
const AutocompleteTagInput = ({ viewControllerManager }: Props) => {
const AutocompleteTagInput = ({ noteTagsController }: Props) => {
const {
autocompleteInputFocused,
autocompleteSearchQuery,
@@ -29,7 +29,7 @@ const AutocompleteTagInput = ({ viewControllerManager }: Props) => {
autocompleteTagResults,
tags,
tagsContainerMaxWidth,
} = viewControllerManager.noteTagsController
} = noteTagsController
const [dropdownVisible, setDropdownVisible] = useState(false)
const [dropdownMaxHeight, setDropdownMaxHeight] = useState<number | 'auto'>('auto')
@@ -39,7 +39,7 @@ const AutocompleteTagInput = ({ viewControllerManager }: Props) => {
const [closeOnBlur] = useCloseOnBlur(containerRef, (visible: boolean) => {
setDropdownVisible(visible)
viewControllerManager.noteTagsController.clearAutocompleteSearch()
noteTagsController.clearAutocompleteSearch()
})
const showDropdown = () => {
@@ -55,17 +55,17 @@ const AutocompleteTagInput = ({ viewControllerManager }: Props) => {
const query = event.target.value
if (query === '') {
viewControllerManager.noteTagsController.clearAutocompleteSearch()
noteTagsController.clearAutocompleteSearch()
} else {
viewControllerManager.noteTagsController.setAutocompleteSearchQuery(query)
viewControllerManager.noteTagsController.searchActiveNoteAutocompleteTags()
noteTagsController.setAutocompleteSearchQuery(query)
noteTagsController.searchActiveNoteAutocompleteTags()
}
}
const onFormSubmit: FormEventHandler = async (event) => {
event.preventDefault()
if (autocompleteSearchQuery !== '') {
await viewControllerManager.noteTagsController.createAndAddNewTag()
await noteTagsController.createAndAddNewTag()
}
}
@@ -74,15 +74,15 @@ const AutocompleteTagInput = ({ viewControllerManager }: Props) => {
case 'Backspace':
case 'ArrowLeft':
if (autocompleteSearchQuery === '' && tags.length > 0) {
viewControllerManager.noteTagsController.setFocusedTagUuid(tags[tags.length - 1].uuid)
noteTagsController.setFocusedTagUuid(tags[tags.length - 1].uuid)
}
break
case 'ArrowDown':
event.preventDefault()
if (autocompleteTagResults.length > 0) {
viewControllerManager.noteTagsController.setFocusedTagResultUuid(autocompleteTagResults[0].uuid)
noteTagsController.setFocusedTagResultUuid(autocompleteTagResults[0].uuid)
} else if (autocompleteTagHintVisible) {
viewControllerManager.noteTagsController.setAutocompleteTagHintFocused(true)
noteTagsController.setAutocompleteTagHintFocused(true)
}
break
default:
@@ -92,19 +92,19 @@ const AutocompleteTagInput = ({ viewControllerManager }: Props) => {
const onFocus = () => {
showDropdown()
viewControllerManager.noteTagsController.setAutocompleteInputFocused(true)
noteTagsController.setAutocompleteInputFocused(true)
}
const onBlur: FocusEventHandler = (event) => {
closeOnBlur(event)
viewControllerManager.noteTagsController.setAutocompleteInputFocused(false)
noteTagsController.setAutocompleteInputFocused(false)
}
useEffect(() => {
if (autocompleteInputFocused) {
inputRef.current?.focus()
}
}, [viewControllerManager, autocompleteInputFocused])
}, [autocompleteInputFocused])
return (
<div ref={containerRef}>
@@ -140,14 +140,14 @@ const AutocompleteTagInput = ({ viewControllerManager }: Props) => {
{autocompleteTagResults.map((tagResult: SNTag) => (
<AutocompleteTagResult
key={tagResult.uuid}
viewControllerManager={viewControllerManager}
noteTagsController={noteTagsController}
tagResult={tagResult}
closeOnBlur={closeOnBlur}
/>
))}
</div>
{autocompleteTagHintVisible && (
<AutocompleteTagHint viewControllerManager={viewControllerManager} closeOnBlur={closeOnBlur} />
<AutocompleteTagHint noteTagsController={noteTagsController} closeOnBlur={closeOnBlur} />
)}
</DisclosurePanel>
)}
@@ -1,48 +1,48 @@
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { splitQueryInString } from '@/Utils/StringUtils'
import { SNTag } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FocusEventHandler, KeyboardEventHandler, useEffect, useRef } from 'react'
import Icon from '@/Components/Icon/Icon'
import { NoteTagsController } from '@/Controllers/NoteTagsController'
type Props = {
viewControllerManager: ViewControllerManager
noteTagsController: NoteTagsController
tagResult: SNTag
closeOnBlur: (event: { relatedTarget: EventTarget | null }) => void
}
const AutocompleteTagResult = ({ viewControllerManager, tagResult, closeOnBlur }: Props) => {
const AutocompleteTagResult = ({ noteTagsController, tagResult, closeOnBlur }: Props) => {
const { autocompleteSearchQuery, autocompleteTagHintVisible, autocompleteTagResults, focusedTagResultUuid } =
viewControllerManager.noteTagsController
noteTagsController
const tagResultRef = useRef<HTMLButtonElement>(null)
const title = tagResult.title
const prefixTitle = viewControllerManager.noteTagsController.getPrefixTitle(tagResult)
const prefixTitle = noteTagsController.getPrefixTitle(tagResult)
const onTagOptionClick = async (tag: SNTag) => {
await viewControllerManager.noteTagsController.addTagToActiveNote(tag)
viewControllerManager.noteTagsController.clearAutocompleteSearch()
viewControllerManager.noteTagsController.setAutocompleteInputFocused(true)
await noteTagsController.addTagToActiveNote(tag)
noteTagsController.clearAutocompleteSearch()
noteTagsController.setAutocompleteInputFocused(true)
}
const onKeyDown: KeyboardEventHandler = (event) => {
const tagResultIndex = viewControllerManager.noteTagsController.getTagIndex(tagResult, autocompleteTagResults)
const tagResultIndex = noteTagsController.getTagIndex(tagResult, autocompleteTagResults)
switch (event.key) {
case 'ArrowUp':
event.preventDefault()
if (tagResultIndex === 0) {
viewControllerManager.noteTagsController.setAutocompleteInputFocused(true)
noteTagsController.setAutocompleteInputFocused(true)
} else {
viewControllerManager.noteTagsController.focusPreviousTagResult(tagResult)
noteTagsController.focusPreviousTagResult(tagResult)
}
break
case 'ArrowDown':
event.preventDefault()
if (tagResultIndex === autocompleteTagResults.length - 1 && autocompleteTagHintVisible) {
viewControllerManager.noteTagsController.setAutocompleteTagHintFocused(true)
noteTagsController.setAutocompleteTagHintFocused(true)
} else {
viewControllerManager.noteTagsController.focusNextTagResult(tagResult)
noteTagsController.focusNextTagResult(tagResult)
}
break
default:
@@ -51,20 +51,20 @@ const AutocompleteTagResult = ({ viewControllerManager, tagResult, closeOnBlur }
}
const onFocus = () => {
viewControllerManager.noteTagsController.setFocusedTagResultUuid(tagResult.uuid)
noteTagsController.setFocusedTagResultUuid(tagResult.uuid)
}
const onBlur: FocusEventHandler = (event) => {
closeOnBlur(event)
viewControllerManager.noteTagsController.setFocusedTagResultUuid(undefined)
noteTagsController.setFocusedTagResultUuid(undefined)
}
useEffect(() => {
if (focusedTagResultUuid === tagResult.uuid) {
tagResultRef.current?.focus()
viewControllerManager.noteTagsController.setFocusedTagResultUuid(undefined)
noteTagsController.setFocusedTagResultUuid(undefined)
}
}, [viewControllerManager, focusedTagResultUuid, tagResult])
}, [noteTagsController, focusedTagResultUuid, tagResult])
return (
<button
@@ -99,6 +99,8 @@ export const STRING_UPGRADE_ACCOUNT_CONFIRM_BUTTON = 'Upgrade'
export const STRING_REMOVE_OFFLINE_KEY_CONFIRMATION = 'This will delete the previously saved offline key.'
export const STRING_DELETE_ACCOUNT_CONFIRMATION = 'Are you sure you want to permanently delete your account?'
export const STRING_FAILED_TO_UPDATE_USER_SETTING =
'There was an error while trying to update your settings. Please try again.'
@@ -121,7 +123,7 @@ export const StringUtils = {
if (!application.hasAccount()) {
return null
}
const platform = getPlatform()
const platform = getPlatform(application.deviceInterface)
const keychainName =
platform === Platform.WindowsDesktop
? 'credential manager'
@@ -24,6 +24,7 @@ export class AccountMenuController extends AbstractViewController {
encryptionStatusString = ''
isBackupEncrypted = false
showSignIn = false
deletingAccount = false
showRegister = false
shouldAnimateCloseMenu = false
currentPane = AccountMenuPane.GeneralMenu
@@ -49,6 +50,7 @@ export class AccountMenuController extends AbstractViewController {
encryptionStatusString: observable,
isBackupEncrypted: observable,
showSignIn: observable,
deletingAccount: observable,
showRegister: observable,
currentPane: observable,
shouldAnimateCloseMenu: observable,
@@ -64,6 +66,7 @@ export class AccountMenuController extends AbstractViewController {
setCurrentPane: action,
setEnableServerOption: action,
setServer: action,
setDeletingAccount: action,
notesAndTagsCount: computed,
})
@@ -155,6 +158,10 @@ export class AccountMenuController extends AbstractViewController {
this.currentPane = pane
}
setDeletingAccount = (deletingAccount: boolean): void => {
this.deletingAccount = deletingAccount
}
get notesAndTagsCount(): number {
return this.notesAndTags.length
}
@@ -188,6 +188,7 @@ export class ItemListController extends AbstractViewController implements Intern
notes: observable,
notesToDisplay: observable,
panelTitle: observable,
panelWidth: observable,
renderedItems: observable,
showDisplayOptionsMenu: observable,
@@ -453,17 +454,21 @@ export class ItemListController extends AbstractViewController implements Intern
this.displayOptions = newDisplayOptions
this.webDisplayOptions = newWebDisplayOptions
const newWidth = this.application.getPreference(PrefKey.NotesPanelWidth)
if (newWidth && newWidth !== this.panelWidth) {
this.panelWidth = newWidth
}
if (!displayOptionsChanged) {
return
}
if (displayOptionsChanged) {
this.reloadNotesDisplayOptions()
}
await this.reloadItems(ItemsReloadSource.DisplayOptionsChange)
const width = this.application.getPreference(PrefKey.NotesPanelWidth)
if (width) {
this.panelWidth = width
}
if (newDisplayOptions.sortBy !== currentSortBy) {
await this.selectFirstItem()
}
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
// Follows https://tailwindcss.com/docs/responsive-design
export const MediaQueryBreakpoints = {
sm: '(min-width: 640px)',
md: '(min-width: 768px)',
lg: '(min-width: 1024px)',
xl: '(min-width: 1280px)',
'2xl': '(min-width: 1536px)',
} as const
export const useMediaQuery = (mediaQuery: string) => {
const [matches, setMatches] = useState(() => window.matchMedia(mediaQuery).matches)
useEffect(() => {
const handler = (event: MediaQueryListEvent) => {
setMatches(event.matches)
}
window.matchMedia(mediaQuery).addEventListener('change', handler)
return () => window.matchMedia(mediaQuery).removeEventListener('change', handler)
}, [mediaQuery])
return matches
}
@@ -0,0 +1,22 @@
type Listener = () => boolean
type RemoveListener = () => void
export class AndroidBackHandler {
private listeners = new Set<Listener>()
addEventListener(listener: Listener): RemoveListener {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
notifyEvent() {
for (const listener of Array.from(this.listeners).reverse()) {
if (listener()) {
return
}
}
}
}
@@ -0,0 +1,67 @@
import { WebApplication } from '@/Application/Application'
import { getBase64FromBlob } from '@/Utils'
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
import { parseFileName } from '@standardnotes/filepicker'
import { Platform, SNNote } from '@standardnotes/snjs'
import { addToast, dismissToast, ToastType } from '@standardnotes/toast'
import { sanitizeFileName } from '@standardnotes/ui-services'
export const downloadSelectedItemsOnAndroid = async (application: WebApplication, notes: SNNote[]) => {
if (!application.isNativeMobileWeb() || application.platform !== Platform.Android) {
throw new Error('Function being used on non-android platform')
}
if (notes.length === 1) {
const note = notes[0]
const blob = getNoteBlob(application, note)
const base64 = await getBase64FromBlob(blob)
const { name, ext } = parseFileName(getNoteFileName(application, note))
const filename = `${sanitizeFileName(name)}.${ext}`
const loadingToastId = addToast({
type: ToastType.Loading,
message: `Exporting ${filename}..`,
})
const downloaded = await application.mobileDevice.downloadBase64AsFile(base64, filename)
if (downloaded) {
dismissToast(loadingToastId)
addToast({
type: ToastType.Success,
message: `Exported ${filename}`,
})
} else {
addToast({
type: ToastType.Error,
message: `Could not export ${filename}`,
})
}
return
}
if (notes.length > 1) {
const zippedDataBlob = await application.getArchiveService().zipData(
notes.map((note) => {
return {
name: getNoteFileName(application, note),
content: getNoteBlob(application, note),
}
}),
)
const zippedDataAsBase64 = await getBase64FromBlob(zippedDataBlob)
const filename = `Standard Notes Export - ${application.getArchiveService().formattedDateForExports()}.zip`
const loadingToastId = addToast({
type: ToastType.Loading,
message: `Exporting ${filename}..`,
})
const downloaded = await application.mobileDevice.downloadBase64AsFile(zippedDataAsBase64, filename)
if (downloaded) {
dismissToast(loadingToastId)
addToast({
type: ToastType.Success,
message: `Exported ${filename}`,
})
} else {
addToast({
type: ToastType.Error,
message: `Could not export ${filename}`,
})
}
}
}
@@ -0,0 +1,36 @@
import { WebApplication } from '@/Application/Application'
import { getBase64FromBlob } from '@/Utils'
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
import { parseFileName } from '@standardnotes/filepicker'
import { SNNote } from '@standardnotes/snjs'
import { sanitizeFileName } from '@standardnotes/ui-services'
export const shareSelectedItems = async (application: WebApplication, notes: SNNote[]) => {
if (!application.isNativeMobileWeb()) {
throw new Error('Share function being used outside mobile webview')
}
if (notes.length === 1) {
const note = notes[0]
const blob = getNoteBlob(application, note)
const base64 = await getBase64FromBlob(blob)
const { name, ext } = parseFileName(getNoteFileName(application, note))
const filename = `${sanitizeFileName(name)}.${ext}`
application.mobileDevice.shareBase64AsFile(base64, filename)
return
}
if (notes.length > 1) {
const zippedDataBlob = await application.getArchiveService().zipData(
notes.map((note) => {
return {
name: getNoteFileName(application, note),
content: getNoteBlob(application, note),
}
}),
)
const zippedDataAsBase64 = await getBase64FromBlob(zippedDataBlob)
application.mobileDevice.shareBase64AsFile(
zippedDataAsBase64,
`Standard Notes Export - ${application.getArchiveService().formattedDateForExports()}.zip`,
)
}
}
@@ -0,0 +1,54 @@
import { WebApplication } from '@/Application/Application'
import { observer } from 'mobx-react-lite'
import { createContext, memo, ReactNode, useCallback, useContext, useEffect } from 'react'
type BackHandlerContextData = WebApplication['addAndroidBackHandlerEventListener']
const BackHandlerContext = createContext<BackHandlerContextData | null>(null)
export const useAndroidBackHandler = () => {
const value = useContext(BackHandlerContext)
if (!value) {
throw new Error('Component must be a child of <AndroidBackHandlerProvider />')
}
return value
}
type ChildrenProps = {
children: ReactNode
}
type ProviderProps = {
application: WebApplication
} & ChildrenProps
const MemoizedChildren = memo(({ children }: ChildrenProps) => <div>{children}</div>)
const AndroidBackHandlerProvider = ({ application, children }: ProviderProps) => {
const addAndroidBackHandler = useCallback(
(listener: () => boolean) => application.addAndroidBackHandlerEventListener(listener),
[application],
)
useEffect(() => {
const removeListener = addAndroidBackHandler(() => {
application.mobileDevice.confirmAndExit()
return true
})
return () => {
if (removeListener) {
removeListener()
}
}
}, [addAndroidBackHandler, application])
return (
<BackHandlerContext.Provider value={addAndroidBackHandler}>
<MemoizedChildren children={children} />
</BackHandlerContext.Provider>
)
}
export default observer(AndroidBackHandlerProvider)
@@ -14,6 +14,11 @@ export const formatDateForContextMenu = (date: Date | undefined) => {
export const formatDateAndTimeForNote = (date: Date) => {
return `${date.toLocaleDateString(undefined, {
dateStyle: 'full',
})} ${date.toLocaleTimeString()}`
weekday: 'long',
day: 'numeric',
month: 'short',
year: 'numeric',
})} at ${date.toLocaleTimeString(undefined, {
timeStyle: 'short',
})}`
}
@@ -0,0 +1,36 @@
import { WebApplication } from '@/Application/Application'
import { SNNote } from '@standardnotes/snjs'
export const getNoteFormat = (application: WebApplication, note: SNNote) => {
const editor = application.componentManager.editorForNote(note)
const format = editor?.package_info?.file_type || 'txt'
return format
}
export const getNoteFileName = (application: WebApplication, note: SNNote): string => {
const format = getNoteFormat(application, note)
return `${note.title}.${format}`
}
export const getNoteBlob = (application: WebApplication, note: SNNote) => {
const format = getNoteFormat(application, note)
let type: string
switch (format) {
case 'html':
type = 'text/html'
break
case 'json':
type = 'application/json'
break
case 'md':
type = 'text/markdown'
break
default:
type = 'text/plain'
break
}
const blob = new Blob([note.text], {
type,
})
return blob
}
+20 -10
View File
@@ -1,6 +1,7 @@
import { Platform, platformFromString } from '@standardnotes/snjs'
import { DeviceInterface, MobileDeviceInterface, Platform, platformFromString } from '@standardnotes/snjs'
import { IsDesktopPlatform, IsWebPlatform } from '@/Constants/Version'
import { EMAIL_REGEX } from '../Constants/Constants'
import { MediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
declare const process: {
env: {
@@ -30,7 +31,11 @@ export function getPlatformString() {
}
}
export function getPlatform(): Platform {
export function getPlatform(device: DeviceInterface | MobileDeviceInterface): Platform {
if ('platform' in device) {
return device.platform
}
return platformFromString(getPlatformString())
}
@@ -202,13 +207,18 @@ export const disableIosTextFieldZoom = () => {
}
}
export const isMobileScreen = () => !window.matchMedia('(min-width: 768px)').matches
export const isMobileScreen = () => !window.matchMedia(MediaQueryBreakpoints.md).matches
export const fitNodeToMobileScreen = (node: HTMLElement | null) => {
if (!node || !isMobileScreen()) {
return
}
node.style.height = `${visualViewport.height}px`
node.style.position = 'absolute'
node.style.top = `${document.documentElement.scrollTop}px`
export const getBase64FromBlob = (blob: Blob) => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onloadend = () => {
if (reader.result) {
resolve(reader.result.toString())
} else {
reject()
}
}
reader.readAsDataURL(blob)
})
}

Some files were not shown because too many files have changed in this diff Show More