mirror of
https://github.com/standardnotes/app
synced 2026-09-15 06:45:59 -04:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6d0aa0f0a | ||
|
|
eafa65e934 | ||
|
|
a83003ee69 | ||
|
|
554f59a3fe | ||
|
|
c0af596f2d | ||
|
|
6e10911d02 | ||
|
|
9502a7fa32 | ||
|
|
727d78cfe2 | ||
|
|
44bb4b7ca3 | ||
|
|
981d8a7497 | ||
|
|
04245dfeeb | ||
|
|
5db47e83e8 | ||
|
|
c08602b7b8 | ||
|
|
ec52e5684d | ||
|
|
f3f7f9d362 | ||
|
|
b9a07d8b5a | ||
|
|
31213bf268 | ||
|
|
70e57e32b3 | ||
|
|
720215eea7 | ||
|
|
6d5ebdeaa1 | ||
|
|
c99693876f | ||
|
|
f8f6a5212b | ||
|
|
58cd919267 | ||
|
|
507823dc4f | ||
|
|
6ea1def0cf | ||
|
|
fe2ce9f1e8 | ||
|
|
4985787c5d | ||
|
|
8614a638a4 | ||
|
|
15957c71cb | ||
|
|
59e814bcec | ||
|
|
4cf9ebf52a | ||
|
|
a3611c802f | ||
|
|
9be315730d | ||
|
|
789a78bda3 | ||
|
|
73e170dedd | ||
|
|
e4b1b64119 | ||
|
|
c2a1918843 | ||
|
|
d3621d70b1 | ||
|
|
bf286f4854 | ||
|
|
97835e64ac |
BIN
Binary file not shown.
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.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.8.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-23)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/api
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/api",
|
||||
"version": "1.8.2",
|
||||
"version": "1.8.4",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,74 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [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
|
||||
|
||||
## [3.23.153](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.152](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-23)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/desktop
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@standardnotes/desktop",
|
||||
"main": "./app/dist/index.js",
|
||||
"version": "3.23.152",
|
||||
"version": "3.23.169",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": "Standard Notes.",
|
||||
"private": true,
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.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.15.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-23)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/encryption
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/encryption",
|
||||
"version": "1.15.4",
|
||||
"version": "1.15.6",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.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.23.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-23)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/filepicker
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/filepicker",
|
||||
"version": "1.23.4",
|
||||
"version": "1.23.6",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.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.10.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-23)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/files
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/files",
|
||||
"version": "1.10.4",
|
||||
"version": "1.10.6",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
## [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
|
||||
|
||||
## [3.36.4](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.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/mobile",
|
||||
"version": "3.36.3",
|
||||
"version": "3.37.3",
|
||||
"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()
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.19.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
|
||||
|
||||
### Features
|
||||
|
||||
* new note title format w/ prefs ([#1629](https://github.com/standardnotes/app/issues/1629)) ([d3621d7](https://github.com/standardnotes/app/commit/d3621d70b1a34537417b06830275467055b753cb))
|
||||
|
||||
## [1.18.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/models",
|
||||
"version": "1.18.4",
|
||||
"version": "1.19.0",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -32,6 +32,13 @@ export enum PrefKey {
|
||||
MobileDoNotShowAgainUnsupportedEditors = 'mobileDoNotShowAgainUnsupportedEditors',
|
||||
MobileSelectedTagUuid = 'mobileSelectedTagUuid',
|
||||
MobileNotesHideEditorIcon = 'mobileHideEditorIcon',
|
||||
NewNoteTitleFormat = 'newNoteTitleFormat',
|
||||
}
|
||||
|
||||
export enum NewNoteTitleFormat {
|
||||
CurrentDateAndTime = 'CurrentDateAndTime',
|
||||
CurrentNoteCount = 'CurrentNoteCount',
|
||||
Empty = 'Empty',
|
||||
}
|
||||
|
||||
export type PrefValue = {
|
||||
@@ -65,4 +72,5 @@ export type PrefValue = {
|
||||
[PrefKey.MobileDoNotShowAgainUnsupportedEditors]: boolean
|
||||
[PrefKey.MobileSelectedTagUuid]: string | undefined
|
||||
[PrefKey.MobileNotesHideEditorIcon]: boolean
|
||||
[PrefKey.NewNoteTitleFormat]: NewNoteTitleFormat
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
## [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.3.76](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.75](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-23)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/releases
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/releases",
|
||||
"version": "1.3.75",
|
||||
"version": "1.3.94",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"main": "dist/releases.json",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -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.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.21.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/services",
|
||||
"version": "1.21.3",
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "*"
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,24 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [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
|
||||
|
||||
## [2.132.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-23)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/snjs
|
||||
|
||||
@@ -3,4 +3,5 @@ export enum ReactNativeToWebEvent {
|
||||
ResumingFromBackground = 'ResumingFromBackground',
|
||||
GainingFocus = 'GainingFocus',
|
||||
LosingFocus = 'LosingFocus',
|
||||
AndroidBackButtonPressed = 'AndroidBackButtonPressed',
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/snjs",
|
||||
"version": "2.132.4",
|
||||
"version": "2.133.1",
|
||||
"engines": {
|
||||
"node": ">=16.0.0 <17.0.0"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,30 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [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.3.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/ui-services",
|
||||
"version": "1.3.1",
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,107 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [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
|
||||
|
||||
* new note title format w/ prefs ([#1629](https://github.com/standardnotes/app/issues/1629)) ([d3621d7](https://github.com/standardnotes/app/commit/d3621d70b1a34537417b06830275467055b753cb))
|
||||
|
||||
# [3.56.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-24)
|
||||
|
||||
### Features
|
||||
|
||||
* flatten quick settings menu and add separate prefs button ([#1631](https://github.com/standardnotes/app/issues/1631)) ([97835e6](https://github.com/standardnotes/app/commit/97835e64ac0ebea829a923af082880b269a2965e))
|
||||
|
||||
## [3.55.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-23)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/web
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@standardnotes/web",
|
||||
"version": "3.55.2",
|
||||
"version": "3.62.3",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"main": "dist/app.js",
|
||||
"author": "Standard Notes.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -150,7 +152,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 +167,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
</AndroidBackHandlerProvider>
|
||||
)
|
||||
}, [viewControllerManager, challenges, mainApplicationGroup, removeChallenge, application])
|
||||
|
||||
@@ -174,76 +176,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={appClass + ' app app-column-container'}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+70
@@ -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)
|
||||
@@ -3,6 +3,7 @@ import { useRef } from 'react'
|
||||
import AccountMenu, { AccountMenuProps } from '../AccountMenu/AccountMenu'
|
||||
import Icon from '../Icon/Icon'
|
||||
import Popover from '../Popover/Popover'
|
||||
import StyledTooltip from '../StyledTooltip/StyledTooltip'
|
||||
|
||||
type Props = AccountMenuProps & {
|
||||
isOpen: boolean
|
||||
@@ -25,18 +26,20 @@ const AccountMenuButton = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={toggleMenu}
|
||||
className={classNames(
|
||||
isOpen ? 'bg-border' : '',
|
||||
'flex h-full w-8 cursor-pointer items-center justify-center rounded-full',
|
||||
)}
|
||||
>
|
||||
<div className={hasError ? 'text-danger' : user ? 'text-info' : 'text-neutral'}>
|
||||
<Icon type="account-circle" className="h-5 w-5 hover:text-info" />
|
||||
</div>
|
||||
</button>
|
||||
<StyledTooltip label="Open account menu">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={toggleMenu}
|
||||
className={classNames(
|
||||
isOpen ? 'bg-border' : '',
|
||||
'flex h-full w-8 cursor-pointer items-center justify-center rounded-full',
|
||||
)}
|
||||
>
|
||||
<div className={hasError ? 'text-danger' : user ? 'text-info' : 'text-neutral'}>
|
||||
<Icon type="account-circle" className="h-5 w-5 hover:text-info" />
|
||||
</div>
|
||||
</button>
|
||||
</StyledTooltip>
|
||||
<Popover anchorElement={buttonRef.current} open={isOpen} togglePopover={toggleMenu} side="top" className="py-2">
|
||||
<AccountMenu
|
||||
onClickOutside={onClickOutside}
|
||||
|
||||
@@ -18,6 +18,8 @@ import { AccountMenuPane } from '../AccountMenu/AccountMenuPane'
|
||||
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
|
||||
@@ -336,6 +338,11 @@ class Footer extends PureComponent<Props, State> {
|
||||
this.viewControllerManager.quickSettingsMenuController.closeQuickSettingsMenu()
|
||||
}
|
||||
|
||||
openPreferences = () => {
|
||||
this.clickOutsideQuickSettingsMenu()
|
||||
this.viewControllerManager.preferencesController.openPreferences()
|
||||
}
|
||||
|
||||
override render() {
|
||||
return (
|
||||
<div className="sn-component">
|
||||
@@ -356,15 +363,30 @@ class Footer extends PureComponent<Props, State> {
|
||||
viewControllerManager={this.viewControllerManager}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative z-footer-bar-item select-none">
|
||||
<StyledTooltip label="Open preferences">
|
||||
<button
|
||||
onClick={this.openPreferences}
|
||||
className="flex h-full w-8 cursor-pointer items-center justify-center"
|
||||
>
|
||||
<div className="h-5">
|
||||
<Icon type="tune" className="rounded hover:text-info" />
|
||||
</div>
|
||||
</button>
|
||||
</StyledTooltip>
|
||||
</div>
|
||||
<div className="relative z-footer-bar-item select-none">
|
||||
<QuickSettingsButton
|
||||
isOpen={this.state.showQuickSettingsMenu}
|
||||
toggleMenu={this.quickSettingsClickHandler}
|
||||
application={this.application}
|
||||
preferencesController={this.viewControllerManager.preferencesController}
|
||||
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">
|
||||
@@ -417,14 +439,16 @@ class Footer extends PureComponent<Props, State> {
|
||||
</div>
|
||||
)}
|
||||
{this.state.hasPasscode && (
|
||||
<div
|
||||
id="lock-item"
|
||||
onClick={this.lockClickHandler}
|
||||
title="Locks application and wipes unencrypted data from memory."
|
||||
className="relative z-footer-bar-item ml-3 flex cursor-pointer select-none items-center border-l border-solid border-border pl-2 hover:text-info"
|
||||
>
|
||||
<Icon type="lock-filled" size="custom" className="h-4.5 w-4.5" />
|
||||
</div>
|
||||
<StyledTooltip label="Lock application">
|
||||
<div
|
||||
id="lock-item"
|
||||
onClick={this.lockClickHandler}
|
||||
title="Locks application and wipes unencrypted data from memory."
|
||||
className="relative z-footer-bar-item ml-3 flex cursor-pointer select-none items-center border-l border-solid border-border pl-2 hover:text-info"
|
||||
>
|
||||
<Icon type="lock-filled" size="custom" className="h-4.5 w-4.5" />
|
||||
</div>
|
||||
</StyledTooltip>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,40 +1,35 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { PreferencesController } from '@/Controllers/PreferencesController'
|
||||
import { QuickSettingsController } from '@/Controllers/QuickSettingsController'
|
||||
import { classNames } from '@/Utils/ConcatenateClassNames'
|
||||
import { useRef } from 'react'
|
||||
import Icon from '../Icon/Icon'
|
||||
import Popover from '../Popover/Popover'
|
||||
import QuickSettingsMenu from '../QuickSettingsMenu/QuickSettingsMenu'
|
||||
import StyledTooltip from '../StyledTooltip/StyledTooltip'
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean
|
||||
toggleMenu: () => void
|
||||
application: WebApplication
|
||||
preferencesController: PreferencesController
|
||||
quickSettingsMenuController: QuickSettingsController
|
||||
}
|
||||
|
||||
const QuickSettingsButton = ({
|
||||
application,
|
||||
isOpen,
|
||||
toggleMenu,
|
||||
preferencesController,
|
||||
quickSettingsMenuController,
|
||||
}: Props) => {
|
||||
const QuickSettingsButton = ({ application, isOpen, toggleMenu, quickSettingsMenuController }: Props) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className="flex h-full w-8 cursor-pointer items-center justify-center"
|
||||
ref={buttonRef}
|
||||
>
|
||||
<div className="h-5">
|
||||
<Icon type="tune" className={classNames(isOpen && 'text-info', 'rounded hover:text-info')} />
|
||||
</div>
|
||||
</button>
|
||||
<StyledTooltip label="Open quick settings menu">
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className="flex h-full w-8 cursor-pointer items-center justify-center"
|
||||
ref={buttonRef}
|
||||
>
|
||||
<div className="h-5">
|
||||
<Icon type="themes" className={classNames(isOpen && 'text-info', 'rounded hover:text-info')} />
|
||||
</div>
|
||||
</button>
|
||||
</StyledTooltip>
|
||||
<Popover
|
||||
togglePopover={toggleMenu}
|
||||
anchorElement={buttonRef.current}
|
||||
@@ -43,11 +38,7 @@ const QuickSettingsButton = ({
|
||||
align="start"
|
||||
className="py-2"
|
||||
>
|
||||
<QuickSettingsMenu
|
||||
preferencesController={preferencesController}
|
||||
quickSettingsMenuController={quickSettingsMenuController}
|
||||
application={application}
|
||||
/>
|
||||
<QuickSettingsMenu quickSettingsMenuController={quickSettingsMenuController} application={application} />
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -62,7 +63,7 @@ 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 w-[220px] overflow-hidden pt-safe-top md:h-full md:max-h-full md:min-h-0 md:py-0 xsm-only:!w-full sm-only:!w-full',
|
||||
isIOS() ? 'pb-safe-bottom' : 'pb-2.5',
|
||||
)}
|
||||
ref={ref}
|
||||
@@ -86,7 +87,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 +96,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()
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
@@ -63,24 +64,22 @@ const PositionedPopoverContent = ({
|
||||
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'
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
@@ -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)
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ComponentMutator,
|
||||
SNComponent,
|
||||
StorageValueModes,
|
||||
NewNoteTitleFormat,
|
||||
} from '@standardnotes/snjs'
|
||||
import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
@@ -61,6 +62,14 @@ const Defaults: FunctionComponent<Props> = ({ application }) => {
|
||||
|
||||
const [spellcheck, setSpellcheck] = useState(() => application.getPreference(PrefKey.EditorSpellcheck, true))
|
||||
|
||||
const [newNoteTitleFormat, setNewNoteTitleFormat] = useState(() =>
|
||||
application.getPreference(PrefKey.NewNoteTitleFormat, NewNoteTitleFormat.CurrentDateAndTime),
|
||||
)
|
||||
const handleNewNoteTitleFormatChange = (value: string) => {
|
||||
setNewNoteTitleFormat(value as NewNoteTitleFormat)
|
||||
application.setPreference(PrefKey.NewNoteTitleFormat, value as NewNoteTitleFormat)
|
||||
}
|
||||
|
||||
const [addNoteToParentFolders, setAddNoteToParentFolders] = useState(() =>
|
||||
application.getPreference(PrefKey.NoteAddToParentFolders, true),
|
||||
)
|
||||
@@ -138,7 +147,7 @@ const Defaults: FunctionComponent<Props> = ({ application }) => {
|
||||
)}
|
||||
<div>
|
||||
<Subtitle>Default Note Type</Subtitle>
|
||||
<Text>New notes will be created using this type.</Text>
|
||||
<Text>New notes will be created using this type</Text>
|
||||
<div className="mt-2">
|
||||
<Dropdown
|
||||
id="def-editor-dropdown"
|
||||
@@ -150,6 +159,33 @@ const Defaults: FunctionComponent<Props> = ({ application }) => {
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalSeparator classes="my-4" />
|
||||
<div>
|
||||
<Subtitle>Default Note Title Format</Subtitle>
|
||||
<Text>New notes will be created with a title in this format</Text>
|
||||
<div className="mt-2">
|
||||
<Dropdown
|
||||
id="def-new-note-title-format"
|
||||
label="Select the default note type"
|
||||
items={[
|
||||
{
|
||||
label: 'Current date and time',
|
||||
value: NewNoteTitleFormat.CurrentDateAndTime,
|
||||
},
|
||||
{
|
||||
label: 'Current note count',
|
||||
value: NewNoteTitleFormat.CurrentNoteCount,
|
||||
},
|
||||
{
|
||||
label: 'Empty',
|
||||
value: NewNoteTitleFormat.Empty,
|
||||
},
|
||||
]}
|
||||
value={newNoteTitleFormat}
|
||||
onChange={handleNewNoteTitleFormatChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<HorizontalSeparator classes="my-4" />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<Subtitle>Spellcheck</Subtitle>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Dispatch, RefObject, SetStateAction } from 'react'
|
||||
|
||||
export const quickSettingsKeyDownHandler = (
|
||||
closeQuickSettingsMenu: () => void,
|
||||
event: React.KeyboardEvent,
|
||||
quickSettingsMenuRef: RefObject<HTMLDivElement>,
|
||||
themesMenuOpen: boolean,
|
||||
) => {
|
||||
if (quickSettingsMenuRef?.current) {
|
||||
const items: NodeListOf<HTMLButtonElement> = quickSettingsMenuRef.current.querySelectorAll(':scope > button')
|
||||
const currentFocusedIndex = Array.from(items).findIndex((btn) => btn === document.activeElement)
|
||||
|
||||
if (!themesMenuOpen) {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
closeQuickSettingsMenu()
|
||||
break
|
||||
case 'ArrowDown':
|
||||
if (items[currentFocusedIndex + 1]) {
|
||||
items[currentFocusedIndex + 1].focus()
|
||||
} else {
|
||||
items[0].focus()
|
||||
}
|
||||
break
|
||||
case 'ArrowUp':
|
||||
if (items[currentFocusedIndex - 1]) {
|
||||
items[currentFocusedIndex - 1].focus()
|
||||
} else {
|
||||
items[items.length - 1].focus()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const themesMenuKeyDownHandler = (
|
||||
event: React.KeyboardEvent<HTMLDivElement>,
|
||||
themesMenuRef: RefObject<HTMLDivElement>,
|
||||
setThemesMenuOpen: Dispatch<SetStateAction<boolean>>,
|
||||
themesButtonRef: RefObject<HTMLButtonElement>,
|
||||
) => {
|
||||
if (themesMenuRef?.current) {
|
||||
const themes = themesMenuRef.current.querySelectorAll('button')
|
||||
const currentFocusedIndex = Array.from(themes).findIndex((themeBtn) => themeBtn === document.activeElement)
|
||||
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
case 'ArrowLeft':
|
||||
event.stopPropagation()
|
||||
setThemesMenuOpen(false)
|
||||
themesButtonRef.current?.focus()
|
||||
break
|
||||
case 'ArrowDown':
|
||||
if (themes[currentFocusedIndex + 1]) {
|
||||
themes[currentFocusedIndex + 1].focus()
|
||||
} else {
|
||||
themes[0].focus()
|
||||
}
|
||||
break
|
||||
case 'ArrowUp':
|
||||
if (themes[currentFocusedIndex - 1]) {
|
||||
themes[currentFocusedIndex - 1].focus()
|
||||
} else {
|
||||
themes[themes.length - 1].focus()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,20 @@
|
||||
import { WebApplication } from '@/Application/Application'
|
||||
import { ComponentArea, ContentType, FeatureIdentifier, GetFeatures, SNComponent } from '@standardnotes/snjs'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { FunctionComponent, KeyboardEventHandler, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import Switch from '@/Components/Switch/Switch'
|
||||
import { quickSettingsKeyDownHandler } from './EventHandlers'
|
||||
import FocusModeSwitch from './FocusModeSwitch'
|
||||
import ThemesMenuButton from './ThemesMenuButton'
|
||||
import { ThemeItem } from './ThemeItem'
|
||||
import { sortThemes } from '@/Utils/SortThemes'
|
||||
import RadioIndicator from '../RadioIndicator/RadioIndicator'
|
||||
import HorizontalSeparator from '../Shared/HorizontalSeparator'
|
||||
import Popover from '../Popover/Popover'
|
||||
import { PreferencesController } from '@/Controllers/PreferencesController'
|
||||
import { QuickSettingsController } from '@/Controllers/QuickSettingsController'
|
||||
|
||||
const focusModeAnimationDuration = 1255
|
||||
|
||||
type MenuProps = {
|
||||
preferencesController: PreferencesController
|
||||
quickSettingsMenuController: QuickSettingsController
|
||||
application: WebApplication
|
||||
}
|
||||
@@ -37,20 +33,13 @@ const toggleFocusMode = (enabled: boolean) => {
|
||||
}
|
||||
}
|
||||
|
||||
const QuickSettingsMenu: FunctionComponent<MenuProps> = ({
|
||||
application,
|
||||
preferencesController,
|
||||
quickSettingsMenuController,
|
||||
}) => {
|
||||
const QuickSettingsMenu: FunctionComponent<MenuProps> = ({ application, quickSettingsMenuController }) => {
|
||||
const { closeQuickSettingsMenu, focusModeEnabled, setFocusModeEnabled } = quickSettingsMenuController
|
||||
const [themes, setThemes] = useState<ThemeItem[]>([])
|
||||
const [toggleableComponents, setToggleableComponents] = useState<SNComponent[]>([])
|
||||
const [themesMenuOpen, setThemesMenuOpen] = useState(false)
|
||||
const [defaultThemeOn, setDefaultThemeOn] = useState(false)
|
||||
|
||||
const themesButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const prefsButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const quickSettingsMenuRef = useRef<HTMLDivElement>(null)
|
||||
const defaultThemeButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const mainRef = useRef<HTMLDivElement>(null)
|
||||
@@ -126,25 +115,10 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = ({
|
||||
}
|
||||
}, [application, reloadToggleableComponents])
|
||||
|
||||
useEffect(() => {
|
||||
if (themesMenuOpen) {
|
||||
defaultThemeButtonRef.current?.focus()
|
||||
}
|
||||
}, [themesMenuOpen])
|
||||
|
||||
useEffect(() => {
|
||||
prefsButtonRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const toggleThemesMenu = useCallback(() => {
|
||||
setThemesMenuOpen((isOpen) => !isOpen)
|
||||
}, [])
|
||||
|
||||
const openPreferences = useCallback(() => {
|
||||
closeQuickSettingsMenu()
|
||||
preferencesController.openPreferences()
|
||||
}, [closeQuickSettingsMenu, preferencesController])
|
||||
|
||||
const toggleComponent = useCallback(
|
||||
(component: SNComponent) => {
|
||||
if (component.isTheme()) {
|
||||
@@ -156,29 +130,6 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = ({
|
||||
[application],
|
||||
)
|
||||
|
||||
const handleBtnKeyDown: React.KeyboardEventHandler<HTMLButtonElement> = useCallback(
|
||||
(event) => {
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
setThemesMenuOpen(false)
|
||||
themesButtonRef.current?.focus()
|
||||
break
|
||||
case 'ArrowRight':
|
||||
if (!themesMenuOpen) {
|
||||
toggleThemesMenu()
|
||||
}
|
||||
}
|
||||
},
|
||||
[themesMenuOpen, toggleThemesMenu],
|
||||
)
|
||||
|
||||
const handleQuickSettingsKeyDown: KeyboardEventHandler<HTMLDivElement> = useCallback(
|
||||
(event) => {
|
||||
quickSettingsKeyDownHandler(closeQuickSettingsMenu, event, quickSettingsMenuRef, themesMenuOpen)
|
||||
},
|
||||
[closeQuickSettingsMenu, themesMenuOpen],
|
||||
)
|
||||
|
||||
const toggleDefaultTheme = useCallback(() => {
|
||||
const activeTheme = themes.map((item) => item.component).find((theme) => theme?.active && !theme.isLayerable())
|
||||
if (activeTheme) {
|
||||
@@ -187,41 +138,21 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = ({
|
||||
}, [application, themes])
|
||||
|
||||
return (
|
||||
<div ref={mainRef} onKeyDown={handleQuickSettingsKeyDown}>
|
||||
<div className="mt-1 mb-2 px-3 text-sm font-semibold uppercase text-text">Quick Settings</div>
|
||||
<div ref={mainRef}>
|
||||
<div className="my-1 px-3 text-sm font-semibold uppercase text-text">Themes</div>
|
||||
<button
|
||||
onClick={toggleThemesMenu}
|
||||
onKeyDown={handleBtnKeyDown}
|
||||
ref={themesButtonRef}
|
||||
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={toggleDefaultTheme}
|
||||
ref={defaultThemeButtonRef}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="themes" className="mr-2 text-neutral" />
|
||||
Themes
|
||||
</div>
|
||||
<Icon type="chevron-right" className="text-neutral" />
|
||||
<RadioIndicator checked={defaultThemeOn} className="mr-2" />
|
||||
Default
|
||||
</button>
|
||||
<Popover
|
||||
togglePopover={toggleThemesMenu}
|
||||
anchorElement={themesButtonRef.current}
|
||||
open={themesMenuOpen}
|
||||
side="right"
|
||||
align="end"
|
||||
className="py-2"
|
||||
>
|
||||
<div className="my-1 px-3 text-sm font-semibold uppercase text-text">Themes</div>
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={toggleDefaultTheme}
|
||||
ref={defaultThemeButtonRef}
|
||||
>
|
||||
<RadioIndicator checked={defaultThemeOn} className="mr-2" />
|
||||
Default
|
||||
</button>
|
||||
{themes.map((theme) => (
|
||||
<ThemesMenuButton item={theme} application={application} key={theme.component?.uuid ?? theme.identifier} />
|
||||
))}
|
||||
</Popover>
|
||||
{themes.map((theme) => (
|
||||
<ThemesMenuButton item={theme} application={application} key={theme.component?.uuid ?? theme.identifier} />
|
||||
))}
|
||||
<HorizontalSeparator classes="my-2" />
|
||||
<div className="my-1 px-3 text-sm font-semibold uppercase text-text">Tools</div>
|
||||
{toggleableComponents.map((component) => (
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center justify-between border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||
@@ -243,15 +174,6 @@ const QuickSettingsMenu: FunctionComponent<MenuProps> = ({
|
||||
onClose={closeQuickSettingsMenu}
|
||||
isEnabled={focusModeEnabled}
|
||||
/>
|
||||
<HorizontalSeparator classes="my-2" />
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center border-0 bg-transparent px-3 py-1.5 text-left text-sm text-text hover:bg-contrast hover:text-foreground focus:bg-info-backdrop focus:shadow-none"
|
||||
onClick={openPreferences}
|
||||
ref={prefsButtonRef}
|
||||
>
|
||||
<Icon type="more" className="mr-2 text-neutral" />
|
||||
Open Preferences
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
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 = {
|
||||
@@ -20,16 +33,27 @@ 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,
|
||||
)
|
||||
@@ -57,6 +81,27 @@ 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 contextValue = useMemo(
|
||||
() => ({
|
||||
selectedPane: currentSelectedPane,
|
||||
|
||||
+23
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import Tooltip from '@reach/tooltip'
|
||||
import styled from 'styled-components'
|
||||
|
||||
export default styled(Tooltip)`
|
||||
&[data-reach-tooltip] {
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
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
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
FileViewController,
|
||||
FileItem,
|
||||
WebAppEvent,
|
||||
NewNoteTitleFormat,
|
||||
} from '@standardnotes/snjs'
|
||||
import { action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'
|
||||
import { WebApplication } from '../../Application/Application'
|
||||
@@ -29,6 +30,7 @@ import { SearchOptionsController } from '../SearchOptionsController'
|
||||
import { SelectedItemsController } from '../SelectedItemsController'
|
||||
import { NotesController } from '../NotesController'
|
||||
import { NoteTagsController } from '../NoteTagsController'
|
||||
import { formatDateAndTimeForNote } from '@/Utils/DateUtils'
|
||||
|
||||
const MinNoteCellHeight = 51.0
|
||||
const DefaultListNumNotes = 20
|
||||
@@ -485,7 +487,19 @@ export class ItemListController extends AbstractViewController implements Intern
|
||||
await this.navigationController.selectHomeNavigationView()
|
||||
}
|
||||
|
||||
let title = `Note ${this.notes.length + 1}`
|
||||
const titleFormat = this.application.getPreference(
|
||||
PrefKey.NewNoteTitleFormat,
|
||||
NewNoteTitleFormat.CurrentDateAndTime,
|
||||
)
|
||||
|
||||
let title = formatDateAndTimeForNote(new Date())
|
||||
|
||||
if (titleFormat === NewNoteTitleFormat.CurrentNoteCount) {
|
||||
title = `Note ${this.notes.length + 1}`
|
||||
} else if (titleFormat === NewNoteTitleFormat.Empty) {
|
||||
title = ''
|
||||
}
|
||||
|
||||
if (this.isFiltering) {
|
||||
title = this.noteFilterText
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -11,3 +11,14 @@ export const formatDateForContextMenu = (date: Date | undefined) => {
|
||||
|
||||
return `${date.toDateString()} ${date.toLocaleTimeString()}`
|
||||
}
|
||||
|
||||
export const formatDateAndTimeForNote = (date: Date) => {
|
||||
return `${date.toLocaleDateString(undefined, {
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
--z-index-lock-screen: 10000;
|
||||
--z-index-modal: 10000;
|
||||
--z-index-toast: 11000;
|
||||
--z-index-tooltip: 12000;
|
||||
|
||||
--sn-stylekit-base-font-size: 0.813rem;
|
||||
--sn-stylekit-simplified-chinese-font: 'Microsoft Yahei', '微软雅黑体';
|
||||
@@ -189,3 +190,12 @@ $footer-height: 2rem;
|
||||
.icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
button,
|
||||
select,
|
||||
label,
|
||||
a {
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ module.exports = {
|
||||
'lock-screen': 'var(--z-index-lock-screen)',
|
||||
modal: 'var(--z-index-modal)',
|
||||
toast: 'var(--z-index-toast)',
|
||||
tooltip: 'var(--z-index-tooltip)',
|
||||
},
|
||||
boxShadow: {
|
||||
inner:
|
||||
|
||||
@@ -7195,7 +7195,7 @@ __metadata:
|
||||
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
|
||||
@@ -7429,6 +7429,7 @@ __metadata:
|
||||
"@typescript-eslint/parser": ^5.12.1
|
||||
eslint-plugin-prettier: ^4.2.1
|
||||
reflect-metadata: ^0.1.13
|
||||
typescript: "*"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
@@ -7448,6 +7449,7 @@ __metadata:
|
||||
buffer: ^6.0.3
|
||||
chai: ^4.3.6
|
||||
connect: ^3.7.0
|
||||
eslint: "*"
|
||||
eslint-plugin-prettier: "*"
|
||||
libsodium-wrappers: ^0.7.10
|
||||
reflect-metadata: ^0.1.13
|
||||
@@ -7455,6 +7457,7 @@ __metadata:
|
||||
serve-static: ^1.14.2
|
||||
ts-loader: ^9.2.6
|
||||
typedarray-to-buffer: ^4.0.0
|
||||
typescript: "*"
|
||||
uuid: ^8.3.2
|
||||
webpack: "*"
|
||||
webpack-cli: "*"
|
||||
@@ -32971,10 +32974,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-native-share@npm:^7.3.7":
|
||||
version: 7.6.4
|
||||
resolution: "react-native-share@npm:7.6.4"
|
||||
checksum: 22764354738587d9166653b09e014ddecf54562696b7f92353980b12be0f9adef58c5d9f927bd05a5e19f50956e6f14fbe2b7a3105675e5982823512945be315
|
||||
"react-native-share@npm:^7.9.0":
|
||||
version: 7.9.0
|
||||
resolution: "react-native-share@npm:7.9.0"
|
||||
checksum: 8eb2f5b4be8df11224f8f00b2761c2c3b4231590d945f57e8ed91899a455d7b5083bb62aa8a017ed8c15fbdfb95e85f9ee4060ac00c01897ef5c351e756c46af
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user