Compare commits

...
Author SHA1 Message Date
StandardNotes CI 023d1665b6 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
2022-09-20 11:59:06 +00:00
Karol Sójko 4a773fa537 feat(api): add websocket api definitions 2022-09-20 13:21:02 +02:00
Aman Harwara dd5ca0c28c fix: window size on mobile (#1600) 2022-09-20 16:48:03 +05:30
StandardNotes CI 9aa7bc018e chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
2022-09-20 10:37:17 +00:00
Aman Harwara 571919c969 fix: default to notes list on launch on mobile (#1599) 2022-09-20 15:28:45 +05:30
StandardNotes CI 66a25c7556 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2022-09-19 20:47:02 +00:00
Aman Harwara e3be17c7f8 fix: show warning on mobile webview before app quits (#1598) 2022-09-20 01:48:40 +05:30
Mo c4d7761496 feat: mobile web bridge (#1597) 2022-09-19 14:47:15 -05:00
StandardNotes CI f80cc5b822 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].3
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].3
2022-09-19 15:02:19 +00:00
Aman Harwara ecaa2a629f fix: preferences dropdown contrast and color (#1591) 2022-09-19 20:03:23 +05:30
StandardNotes CI 46f3e873a5 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected].2
2022-09-19 13:17:25 +00:00
Karol Sójko 038320b2fd fix(snjs): pass unencrypted file size to comply with file service interface (#1595) 2022-09-19 14:36:53 +02:00
StandardNotes CI ee4a1acb9c chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
2022-09-18 12:37:48 +00:00
Aman Harwara efe0c38462 fix: move save status next to note actions on mobile (#1590) 2022-09-18 17:37:30 +05:30
StandardNotes CI 4336c9ed66 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
2022-09-16 21:52:59 +00:00
Aman Harwara 85c90e07fc feat: add settings to fully switch between native/webview (#1587) 2022-09-17 02:52:38 +05:30
StandardNotes CI 146b3329e0 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].7
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].3
2022-09-16 18:15:47 +00:00
Mo 0c3c98d7af refactor(mobile): default to web frame if isDev (#1586)
* refactor(mobile): default to web frame if isDev

* fix: ignore native challenges

* fix: use contentInset for padding
2022-09-16 12:44:06 -05:00
StandardNotes CI 5a8752b95a chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].6
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
2022-09-16 13:35:11 +00:00
Aman Harwara 7807fa34f3 fix: long titles on non-mobile view (#1575) 2022-09-16 18:35:43 +05:30
StandardNotes CI ddf4c97477 chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].5
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].2
 - @standardnotes/[email protected].1
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].1
2022-09-15 18:43:48 +00:00
Aman Harwara 3a15142940 fix: biometrics input on mobile webview challenge modal (#1572) 2022-09-15 23:41:03 +05:30
StandardNotes CI 79518b6a5d chore(release): publish
- @standardnotes/[email protected]
 - @standardnotes/[email protected].4
 - @standardnotes/[email protected]
 - @standardnotes/[email protected].0
 - @standardnotes/[email protected].0
2022-09-15 10:28:11 +00:00
Karol SójkoandAman Harwara 2d0ee10226 feat: sharing subscriptions UI (#1567)
* feat(web): add ui for subscription sharing

* fix(web): add missing triggers

* fix(snjs): setting authorization token on http service

* fix(web): add alert upon invite failure

* fix(web): display invitations list

* fix(web): canceling subscription invitations

* fix(web): fonts

* fix(web): linter issues

* fix: click event handler

* fix: styles

* feat: update styles

* feat: don't show bottom separator if all invites used

* fix(web): references to alert service

* fix(web): remove usebeforeunload

Co-authored-by: Aman Harwara <[email protected]>
2022-09-15 12:00:29 +02:00
90 changed files with 1670 additions and 232 deletions
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.8.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-20)
### Features
* **api:** add websocket api definitions ([4a773fa](https://github.com/standardnotes/app/commit/4a773fa53796e17ce5df325ed7d40ba2cb686476))
## [1.7.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/api
+4 -4
View File
@@ -10,10 +10,10 @@ module.exports = {
},
coverageThreshold: {
global: {
branches: 17,
functions: 43,
lines: 46,
statements: 46
branches: 20,
functions: 66,
lines: 63,
statements: 63
}
}
};
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/api",
"version": "1.7.2",
"version": "1.8.0",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -32,7 +32,8 @@
"eslint": "^8.23.0",
"eslint-plugin-prettier": "*",
"jest": "^28.1.2",
"ts-jest": "^28.0.5"
"ts-jest": "^28.0.5",
"typescript": "*"
},
"dependencies": {
"@standardnotes/common": "^1.32.0",
@@ -0,0 +1,3 @@
export enum WebSocketApiOperations {
CreatingConnectionToken,
}
@@ -0,0 +1,61 @@
import { WebSocketConnectionTokenResponse } from '../../Response'
import { WebSocketServerInterface } from '../../Server/WebSocket/WebSocketServerInterface'
import { WebSocketApiOperations } from './WebSocketApiOperations'
import { WebSocketApiService } from './WebSocketApiService'
describe('WebSocketApiService', () => {
let webSocketServer: WebSocketServerInterface
const createService = () => new WebSocketApiService(webSocketServer)
beforeEach(() => {
webSocketServer = {} as jest.Mocked<WebSocketServerInterface>
webSocketServer.createConnectionToken = jest.fn().mockReturnValue({
data: { token: 'foobar' },
} as jest.Mocked<WebSocketConnectionTokenResponse>)
})
it('should create a websocket connection token', async () => {
const response = await createService().createConnectionToken()
expect(response).toEqual({
data: {
token: 'foobar',
},
})
expect(webSocketServer.createConnectionToken).toHaveBeenCalledWith({})
})
it('should not create a token if it is already creating', async () => {
const service = createService()
Object.defineProperty(service, 'operationsInProgress', {
get: () => new Map([[WebSocketApiOperations.CreatingConnectionToken, true]]),
})
let error = null
try {
await service.createConnectionToken()
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not create a token if the server fails', async () => {
webSocketServer.createConnectionToken = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().createConnectionToken()
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
})
@@ -0,0 +1,33 @@
import { ErrorMessage } from '../../Error/ErrorMessage'
import { ApiCallError } from '../../Error/ApiCallError'
import { WebSocketApiServiceInterface } from './WebSocketApiServiceInterface'
import { WebSocketApiOperations } from './WebSocketApiOperations'
import { WebSocketServerInterface } from '../../Server'
import { WebSocketConnectionTokenResponse } from '../../Response'
export class WebSocketApiService implements WebSocketApiServiceInterface {
private operationsInProgress: Map<WebSocketApiOperations, boolean>
constructor(private webSocketServer: WebSocketServerInterface) {
this.operationsInProgress = new Map()
}
async createConnectionToken(): Promise<WebSocketConnectionTokenResponse> {
if (this.operationsInProgress.get(WebSocketApiOperations.CreatingConnectionToken)) {
throw new ApiCallError(ErrorMessage.GenericInProgress)
}
this.operationsInProgress.set(WebSocketApiOperations.CreatingConnectionToken, true)
try {
const response = await this.webSocketServer.createConnectionToken({})
this.operationsInProgress.set(WebSocketApiOperations.CreatingConnectionToken, false)
return response
} catch (error) {
throw new ApiCallError(ErrorMessage.GenericFail)
}
}
}
@@ -0,0 +1,5 @@
import { WebSocketConnectionTokenResponse } from '../../Response'
export interface WebSocketApiServiceInterface {
createConnectionToken(): Promise<WebSocketConnectionTokenResponse>
}
+2
View File
@@ -3,3 +3,5 @@ export * from './Subscription/SubscriptionApiService'
export * from './Subscription/SubscriptionApiServiceInterface'
export * from './User/UserApiService'
export * from './User/UserApiServiceInterface'
export * from './WebSocket/WebSocketApiService'
export * from './WebSocket/WebSocketApiServiceInterface'
@@ -0,0 +1,3 @@
export type WebSocketConnectionTokenRequestParams = {
[additionalParam: string]: unknown
}
+1
View File
@@ -5,3 +5,4 @@ export * from './Subscription/SubscriptionInviteDeclineRequestParams'
export * from './Subscription/SubscriptionInviteListRequestParams'
export * from './Subscription/SubscriptionInviteRequestParams'
export * from './User/UserRegistrationRequestParams'
export * from './WebSocket/WebSocketConnectionTokenRequestParams'
@@ -0,0 +1,9 @@
import { Either } from '@standardnotes/common'
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
import { HttpResponse } from '../../Http/HttpResponse'
import { WebSocketConnectionTokenResponseBody } from './WebSocketConnectionTokenResponseBody'
export interface WebSocketConnectionTokenResponse extends HttpResponse {
data: Either<WebSocketConnectionTokenResponseBody, HttpErrorResponseBody>
}
@@ -0,0 +1,3 @@
export type WebSocketConnectionTokenResponseBody = {
token: string
}
@@ -10,3 +10,5 @@ export * from './Subscription/SubscriptionInviteResponse'
export * from './Subscription/SubscriptionInviteResponseBody'
export * from './User/UserRegistrationResponse'
export * from './User/UserRegistrationResponseBody'
export * from './WebSocket/WebSocketConnectionTokenResponse'
export * from './WebSocket/WebSocketConnectionTokenResponseBody'
@@ -0,0 +1,9 @@
const TokenPaths = {
createConnectionToken: '/v1/sockets/tokens',
}
export const Paths = {
v1: {
...TokenPaths,
},
}
@@ -0,0 +1,27 @@
import { HttpServiceInterface } from '../../Http'
import { WebSocketConnectionTokenResponse } from '../../Response'
import { WebSocketServer } from './WebSocketServer'
describe('WebSocketServer', () => {
let httpService: HttpServiceInterface
const createServer = () => new WebSocketServer(httpService)
beforeEach(() => {
httpService = {} as jest.Mocked<HttpServiceInterface>
httpService.post = jest.fn().mockReturnValue({
data: { token: 'foobar' },
} as jest.Mocked<WebSocketConnectionTokenResponse>)
})
it('should create a websocket connection token', async () => {
const response = await createServer().createConnectionToken({})
expect(response).toEqual({
data: {
token: 'foobar',
},
})
})
})
@@ -0,0 +1,17 @@
import { HttpServiceInterface } from '../../Http/HttpServiceInterface'
import { WebSocketConnectionTokenRequestParams } from '../../Request/WebSocket/WebSocketConnectionTokenRequestParams'
import { WebSocketConnectionTokenResponse } from '../../Response/WebSocket/WebSocketConnectionTokenResponse'
import { Paths } from './Paths'
import { WebSocketServerInterface } from './WebSocketServerInterface'
export class WebSocketServer implements WebSocketServerInterface {
constructor(private httpService: HttpServiceInterface) {}
async createConnectionToken(
params: WebSocketConnectionTokenRequestParams,
): Promise<WebSocketConnectionTokenResponse> {
const response = await this.httpService.post(Paths.v1.createConnectionToken, params)
return response as WebSocketConnectionTokenResponse
}
}
@@ -0,0 +1,6 @@
import { WebSocketConnectionTokenRequestParams } from '../../Request/WebSocket/WebSocketConnectionTokenRequestParams'
import { WebSocketConnectionTokenResponse } from '../../Response/WebSocket/WebSocketConnectionTokenResponse'
export interface WebSocketServerInterface {
createConnectionToken(params: WebSocketConnectionTokenRequestParams): Promise<WebSocketConnectionTokenResponse>
}
+2
View File
@@ -2,3 +2,5 @@ export * from './Subscription/SubscriptionServer'
export * from './Subscription/SubscriptionServerInterface'
export * from './User/UserServer'
export * from './User/UserServerInterface'
export * from './WebSocket/WebSocketServer'
export * from './WebSocket/WebSocketServerInterface'
+1
View File
@@ -0,0 +1 @@
dist
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.7.13](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
**Note:** Version bump only for package @standardnotes/components-meta
## [2.7.12](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-13)
**Note:** Version bump only for package @standardnotes/components-meta
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/components-meta",
"version": "2.7.12",
"version": "2.7.13",
"private": true,
"author": "Standard Notes.",
"main": "dist",
+44
View File
@@ -3,6 +3,50 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.23.134](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-20)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.133](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-20)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.132](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.131](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.130](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.129](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-18)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.128](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.127](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.126](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.125](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.124](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/desktop
## [3.23.123](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/desktop
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@standardnotes/desktop",
"main": "./app/dist/index.js",
"version": "3.23.123",
"version": "3.23.134",
"license": "AGPL-3.0-or-later",
"author": "Standard Notes.",
"private": true,
+7 -2
View File
@@ -1,3 +1,8 @@
metro.config.js
packages/mobile/html/Web.bundle/src/components
packages/mobile/html/Web.bundle/src/web-src
html/**/*
node_modules
ios
e2e
android
fastlane
WebFrame
+50
View File
@@ -3,6 +3,56 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.35.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-20)
**Note:** Version bump only for package @standardnotes/mobile
## [3.35.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-20)
**Note:** Version bump only for package @standardnotes/mobile
# [3.35.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
### Features
* mobile web bridge ([#1597](https://github.com/standardnotes/app/issues/1597)) ([c4d7761](https://github.com/standardnotes/app/commit/c4d776149677269bc766f24da6adc5ec816dbcfb))
## [3.34.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
**Note:** Version bump only for package @standardnotes/mobile
## [3.34.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
**Note:** Version bump only for package @standardnotes/mobile
## [3.34.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-18)
**Note:** Version bump only for package @standardnotes/mobile
# [3.34.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
### Features
* add settings to fully switch between native/webview ([#1587](https://github.com/standardnotes/app/issues/1587)) ([85c90e0](https://github.com/standardnotes/app/commit/85c90e07fc8b1ce66eaa88df09f2728ac0fecf1c))
## [3.33.7](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
**Note:** Version bump only for package @standardnotes/mobile
## [3.33.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
**Note:** Version bump only for package @standardnotes/mobile
## [3.33.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
### Bug Fixes
* biometrics input on mobile webview challenge modal ([#1572](https://github.com/standardnotes/app/issues/1572)) ([3a15142](https://github.com/standardnotes/app/commit/3a15142940ef9391868442e1997c3d9d5eec33be))
## [3.33.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/mobile
## [3.33.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/mobile
+5 -3
View File
@@ -1,10 +1,12 @@
import { IsMobileWeb } from '@Lib/Utils'
import { MobileWebApp } from '@Root/MobileWebApp'
import { SNLog } from '@standardnotes/snjs'
import { AppRegistry } from 'react-native'
import 'react-native-gesture-handler'
import { enableScreens } from 'react-native-screens'
import 'react-native-url-polyfill/auto'
import { name as appName } from './app.json'
import { App } from './src/App'
import { NativeApp } from './src/NativeApp'
import { enableAndroidFontFix } from './src/Style/android_text_fix'
enableScreens()
@@ -28,11 +30,11 @@ console.warn = function filterWarnings(msg) {
"[react-native-gesture-handler] Seems like you're using an old API with gesture components",
]
if (!supressedWarnings.some(entry => msg.includes(entry))) {
if (!supressedWarnings.some((entry) => msg.includes(entry))) {
originalWarn.apply(console, arguments)
}
}
enableAndroidFontFix()
AppRegistry.registerComponent(appName, () => App)
AppRegistry.registerComponent(appName, () => (IsMobileWeb ? MobileWebApp : NativeApp))
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/mobile",
"version": "3.33.3",
"version": "3.35.2",
"author": "Standard Notes.",
"private": true,
"license": "AGPL-3.0-or-later",
+25 -29
View File
@@ -2,33 +2,30 @@ import { AppStateEventType, AppStateType, TabletModeChangeData } from '@Lib/Appl
import { AlwaysOpenWebAppOnLaunchKey } from '@Lib/constants'
import { useHasEditor, useIsLocked } from '@Lib/SnjsHelperHooks'
import { ScreenStatus } from '@Lib/StatusManager'
import { IsDev } from '@Lib/Utils'
import { CompositeNavigationProp, RouteProp, useNavigation } from '@react-navigation/native'
import { IsMobileWeb } from '@Lib/Utils'
import { CompositeNavigationProp, RouteProp } from '@react-navigation/native'
import { createStackNavigator, StackNavigationProp } from '@react-navigation/stack'
import { HeaderTitleView } from '@Root/Components/HeaderTitleView'
import { IoniconsHeaderButton } from '@Root/Components/IoniconsHeaderButton'
import { Compose } from '@Root/Screens/Compose/Compose'
import { SCREEN_COMPOSE, SCREEN_NOTES, SCREEN_VIEW_PROTECTED_NOTE, SCREEN_WEB_APP } from '@Root/Screens/screens'
import { SCREEN_COMPOSE, SCREEN_NOTES, SCREEN_VIEW_PROTECTED_NOTE } from '@Root/Screens/screens'
import { MainSideMenu } from '@Root/Screens/SideMenu/MainSideMenu'
import { NoteSideMenu } from '@Root/Screens/SideMenu/NoteSideMenu'
import { ViewProtectedNote } from '@Root/Screens/ViewProtectedNote/ViewProtectedNote'
import { Root } from '@Screens/Root'
import { ApplicationEvent, StorageValueModes, UuidString } from '@standardnotes/snjs'
import { StorageValueModes, UuidString } from '@standardnotes/snjs'
import { ICON_MENU } from '@Style/Icons'
import { ThemeService } from '@Style/ThemeService'
import { getDefaultDrawerWidth } from '@Style/Utils'
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
import { Dimensions, Keyboard, ScaledSize } from 'react-native'
import { Dimensions, Keyboard, ScaledSize, StatusBar } from 'react-native'
import DrawerLayout, { DrawerState } from 'react-native-gesture-handler/DrawerLayout'
import { HeaderButtons, Item } from 'react-navigation-header-buttons'
import { ThemeContext } from 'styled-components'
import { HeaderTitleParams } from './App'
import { ApplicationContext } from './ApplicationContext'
import { MobileWebAppContainer } from './MobileWebAppContainer'
import { ModalStackNavigationProp } from './ModalStack'
const IS_DEBUGGING_WEB_APP = false
const DEFAULT_TO_WEB_APP = IsDev && IS_DEBUGGING_WEB_APP
import { HeaderTitleParams } from './NativeApp'
export type AppStackNavigatorParamList = {
[SCREEN_NOTES]: HeaderTitleParams
@@ -124,27 +121,24 @@ export const AppStackComponent = (props: ModalStackNavigationProp<'AppStack'>) =
[application],
)
const navigation = useNavigation<ModalStackNavigationProp<'AppStack'>['navigation']>()
if (IsMobileWeb) {
return (
<AppStack.Navigator
screenOptions={() => ({
headerShown: false,
})}
initialRouteName={SCREEN_NOTES}
>
<AppStack.Screen name={SCREEN_NOTES} component={MobileWebAppContainer} />
</AppStack.Navigator>
)
}
useEffect(() => {
if (!application) {
return
}
if (!application) {
return null
}
const removeObserver = application.addEventObserver(async (event) => {
if (event === ApplicationEvent.Launched) {
const value = (await application.getValue(AlwaysOpenWebAppOnLaunchKey, StorageValueModes.Nonwrapped)) as
| boolean
| undefined
const shouldAlwaysOpenWebAppOnLaunch = value ?? false
if (shouldAlwaysOpenWebAppOnLaunch) {
navigation.push(SCREEN_WEB_APP)
}
}
})
return removeObserver
}, [application, navigation])
const shouldOpenWebApp = application.getValue(AlwaysOpenWebAppOnLaunchKey, StorageValueModes.Nonwrapped) as boolean
return (
<DrawerLayout
@@ -156,6 +150,7 @@ export const AppStackComponent = (props: ModalStackNavigationProp<'AppStack'>) =
onDrawerStateChanged={handleDrawerStateChange}
renderNavigationView={() => !isLocked && <MainSideMenu drawerRef={drawerRef.current} />}
>
<StatusBar translucent={!shouldOpenWebApp} />
<DrawerLayout
ref={noteDrawerRef}
drawerWidth={getDefaultDrawerWidth(dimensions)}
@@ -185,6 +180,7 @@ export const AppStackComponent = (props: ModalStackNavigationProp<'AppStack'>) =
name={SCREEN_NOTES}
options={({ route }) => ({
title: 'All notes',
headerShown: !shouldOpenWebApp,
headerTitle: ({ children }) => {
const screenStatus = isInTabletMode ? composeStatus || notesStatus : notesStatus
@@ -224,7 +220,7 @@ export const AppStackComponent = (props: ModalStackNavigationProp<'AppStack'>) =
</HeaderButtons>
),
})}
component={DEFAULT_TO_WEB_APP ? MobileWebAppContainer : Root}
component={shouldOpenWebApp ? MobileWebAppContainer : Root}
/>
<AppStack.Screen
name={SCREEN_COMPOSE}
@@ -0,0 +1,62 @@
import { AbstractService, InternalEventBus, ReactNativeToWebEvent } from '@standardnotes/snjs'
import { AppState, AppStateStatus, NativeEventSubscription } from 'react-native'
export class AppStateObserverService extends AbstractService<ReactNativeToWebEvent> {
private mostRecentState?: ReactNativeToWebEvent
private removeListener: NativeEventSubscription
private ignoringStateChanges = false
constructor() {
const bus = new InternalEventBus()
super(bus)
this.removeListener = AppState.addEventListener('change', async (nextAppState: AppStateStatus) => {
if (this.ignoringStateChanges) {
return
}
// if the most recent state is not 'background' ('inactive'), then we're going
// from inactive to active, which doesn't really happen unless you, say, swipe
// notification center in iOS down then back up. We don't want to lock on this state change.
const isResuming = nextAppState === 'active'
const isResumingFromBackground = isResuming && this.mostRecentState === ReactNativeToWebEvent.EnteringBackground
const isEnteringBackground = nextAppState === 'background'
const isLosingFocus = nextAppState === 'inactive'
if (isEnteringBackground) {
this.notifyStateChange(ReactNativeToWebEvent.EnteringBackground)
}
if (isResumingFromBackground || isResuming) {
if (isResumingFromBackground) {
this.notifyStateChange(ReactNativeToWebEvent.ResumingFromBackground)
}
// Notify of GainingFocus even if resuming from background
this.notifyStateChange(ReactNativeToWebEvent.GainingFocus)
return
}
if (isLosingFocus) {
this.notifyStateChange(ReactNativeToWebEvent.LosingFocus)
}
})
}
beginIgnoringStateChanges() {
this.ignoringStateChanges = true
}
stopIgnoringStateChanges() {
this.ignoringStateChanges = false
}
deinit() {
this.removeListener.remove()
}
private notifyStateChange(state: ReactNativeToWebEvent): void {
this.mostRecentState = state
void this.notifyEvent(state)
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ import React, { useContext } from 'react'
import { Platform } from 'react-native'
import { HeaderButtons, Item } from 'react-navigation-header-buttons'
import { ThemeContext } from 'styled-components'
import { HeaderTitleParams } from './App'
import { HeaderTitleParams } from './NativeApp'
type HistoryStackNavigatorParamList = {
[SCREEN_NOTE_HISTORY]: (HeaderTitleParams & { noteUuid: string }) | (undefined & { noteUuid: string })
+7 -3
View File
@@ -23,13 +23,13 @@ import { BackupsService } from './BackupsService'
import { ComponentManager } from './ComponentManager'
import { FilesService } from './FilesService'
import { InstallationService } from './InstallationService'
import { MobileDeviceInterface } from './Interface'
import { MobileDevice } from './Interface'
import { push } from './NavigationService'
import { PreferencesManager } from './PreferencesManager'
import { SNReactNativeCrypto } from './ReactNativeCrypto'
import { ReviewService } from './ReviewService'
import { StatusManager } from './StatusManager'
import { IsDev } from './Utils'
import { IsDev, IsMobileWeb } from './Utils'
type MobileServices = {
applicationState: ApplicationState
@@ -52,7 +52,7 @@ export class MobileApplication extends SNApplication {
static previouslyLaunched = false
constructor(deviceInterface: MobileDeviceInterface, identifier: string) {
constructor(deviceInterface: MobileDevice, identifier: string) {
super({
environment: Environment.Mobile,
platform: platformFromString(Platform.OS),
@@ -135,6 +135,10 @@ export class MobileApplication extends SNApplication {
}
promptForChallenge(challenge: Challenge) {
if (IsMobileWeb) {
return
}
push(SCREEN_AUTHENTICATE, { challenge, title: challenge.modalTitle })
}
+3 -3
View File
@@ -4,14 +4,14 @@ import { ApplicationState } from './ApplicationState'
import { BackupsService } from './BackupsService'
import { FilesService } from './FilesService'
import { InstallationService } from './InstallationService'
import { MobileDeviceInterface } from './Interface'
import { MobileDevice } from './Interface'
import { PreferencesManager } from './PreferencesManager'
import { ReviewService } from './ReviewService'
import { StatusManager } from './StatusManager'
export class ApplicationGroup extends SNApplicationGroup {
constructor() {
super(new MobileDeviceInterface())
super(new MobileDevice())
}
override async initialize(_callback?: any): Promise<void> {
@@ -21,7 +21,7 @@ export class ApplicationGroup extends SNApplicationGroup {
}
private createApplication = async (descriptor: ApplicationDescriptor, deviceInterface: DeviceInterface) => {
const application = new MobileApplication(deviceInterface as MobileDeviceInterface, descriptor.identifier)
const application = new MobileApplication(deviceInterface as MobileDevice, descriptor.identifier)
const internalEventBus = new InternalEventBus()
const applicationState = new ApplicationState(application)
const reviewService = new ReviewService(application, internalEventBus)
+36 -35
View File
@@ -1,4 +1,4 @@
import { MobileDeviceInterface } from '@Lib/Interface'
import { MobileDevice } from '@Lib/Interface'
import {
ApplicationEvent,
ApplicationService,
@@ -169,9 +169,7 @@ export class ApplicationState extends ApplicationService {
override async onAppLaunch() {
MobileApplication.setPreviouslyLaunched()
this.screenshotPrivacyEnabled = (await this.getScreenshotPrivacyEnabled()) ?? true
await (this.application.deviceInterface as MobileDeviceInterface).setAndroidScreenshotPrivacy(
this.screenshotPrivacyEnabled,
)
await (this.application.deviceInterface as MobileDevice).setAndroidScreenshotPrivacy(this.screenshotPrivacyEnabled)
}
/**
@@ -480,28 +478,35 @@ export class ApplicationState extends ApplicationService {
private async checkAndLockApplication() {
const isLocked = await this.application.isLocked()
if (!isLocked) {
const hasBiometrics = await this.application.hasBiometrics()
const hasPasscode = this.application.hasPasscode()
if (hasPasscode && this.passcodeTiming === MobileUnlockTiming.Immediately) {
await this.application.lock()
} else if (hasBiometrics && this.biometricsTiming === MobileUnlockTiming.Immediately && !this.locked) {
const challenge = new Challenge(
[new ChallengePrompt(ChallengeValidation.Biometric)],
ChallengeReason.ApplicationUnlock,
false,
)
void this.application.promptForCustomChallenge(challenge)
this.locked = true
this.notifyLockStateObservers(LockStateType.Locked)
this.application.addChallengeObserver(challenge, {
onComplete: () => {
this.locked = false
this.notifyLockStateObservers(LockStateType.Unlocked)
},
})
}
if (isLocked) {
return
}
const hasBiometrics = this.application.hasBiometrics()
const hasPasscode = this.application.hasPasscode()
const passcodeLockImmediately = hasPasscode && this.passcodeTiming === MobileUnlockTiming.Immediately
const biometricsLockImmediately =
hasBiometrics && this.biometricsTiming === MobileUnlockTiming.Immediately && !this.locked
if (passcodeLockImmediately) {
await this.application.lock()
} else if (biometricsLockImmediately) {
const challenge = new Challenge(
[new ChallengePrompt(ChallengeValidation.Biometric)],
ChallengeReason.ApplicationUnlock,
false,
)
void this.application.promptForCustomChallenge(challenge)
this.locked = true
this.notifyLockStateObservers(LockStateType.Locked)
this.application.addChallengeObserver(challenge, {
onComplete: () => {
this.locked = false
this.notifyLockStateObservers(LockStateType.Unlocked)
},
})
}
}
@@ -570,30 +575,26 @@ export class ApplicationState extends ApplicationService {
}
private async getPasscodeTiming(): Promise<MobileUnlockTiming | undefined> {
return this.application.getValue(StorageKey.MobilePasscodeTiming, StorageValueModes.Nonwrapped) as Promise<
MobileUnlockTiming | undefined
>
return this.application.getMobilePasscodeTiming()
}
private async getBiometricsTiming(): Promise<MobileUnlockTiming | undefined> {
return this.application.getValue(StorageKey.MobileBiometricsTiming, StorageValueModes.Nonwrapped) as Promise<
MobileUnlockTiming | undefined
>
return this.application.getMobileBiometricsTiming()
}
public async setScreenshotPrivacyEnabled(enabled: boolean) {
await this.application.setMobileScreenshotPrivacyEnabled(enabled)
this.screenshotPrivacyEnabled = enabled
await (this.application.deviceInterface as MobileDeviceInterface).setAndroidScreenshotPrivacy(enabled)
await (this.application.deviceInterface as MobileDevice).setAndroidScreenshotPrivacy(enabled)
}
public async setPasscodeTiming(timing: MobileUnlockTiming) {
await this.application.setValue(StorageKey.MobilePasscodeTiming, timing, StorageValueModes.Nonwrapped)
this.application.setValue(StorageKey.MobilePasscodeTiming, timing, StorageValueModes.Nonwrapped)
this.passcodeTiming = timing
}
public async setBiometricsTiming(timing: MobileUnlockTiming) {
await this.application.setValue(StorageKey.MobileBiometricsTiming, timing, StorageValueModes.Nonwrapped)
this.application.setValue(StorageKey.MobileBiometricsTiming, timing, StorageValueModes.Nonwrapped)
this.biometricsTiming = timing
}
@@ -605,7 +606,7 @@ export class ApplicationState extends ApplicationService {
}
public async setPasscodeKeyboardType(type: PasscodeKeyboardType) {
await this.application.setValue(MobileStorageKey.PasscodeKeyboardTypeKey, type, StorageValueModes.Nonwrapped)
this.application.setValue(MobileStorageKey.PasscodeKeyboardTypeKey, type, StorageValueModes.Nonwrapped)
}
public onDrawerOpen() {
@@ -1,6 +1,6 @@
import SNReactNative from '@standardnotes/react-native-utils'
import { ApplicationService, ButtonType, StorageValueModes } from '@standardnotes/snjs'
import { MobileDeviceInterface } from './Interface'
import { MobileDevice } from './Interface'
const FIRST_RUN_KEY = 'first_run'
@@ -23,7 +23,7 @@ export class InstallationService extends ApplicationService {
*/
async needsWipe() {
const hasAccountOrPasscode = this.application.hasAccount() || this.application?.hasPasscode()
const deviceInterface = this.application.deviceInterface as MobileDeviceInterface
const deviceInterface = this.application.deviceInterface as MobileDevice
const keychainKey = await deviceInterface.getNamespacedKeychainValue(this.application.identifier)
const hasKeychainValue = keychainKey != undefined
+109 -7
View File
@@ -2,21 +2,31 @@ import AsyncStorage from '@react-native-community/async-storage'
import SNReactNative from '@standardnotes/react-native-utils'
import {
ApplicationIdentifier,
DeviceInterface,
Environment,
LegacyMobileKeychainStructure,
LegacyRawKeychainValue,
MobileDeviceInterface,
NamespacedRootKeyInKeychain,
RawKeychainValue,
removeFromArray,
TransferPayload,
} from '@standardnotes/snjs'
import { Alert, Linking, Platform } from 'react-native'
import FingerprintScanner from 'react-native-fingerprint-scanner'
import FlagSecure from 'react-native-flag-secure-android'
import { hide, show } from 'react-native-privacy-snapshot'
import { AppStateObserverService } from './../AppStateObserverService'
import Keychain from './Keychain'
import { IsMobileWeb } from './Utils'
export type BiometricsType = 'Fingerprint' | 'Face ID' | 'Biometrics' | 'Touch ID'
export enum MobileDeviceEvent {
RequestsWebViewReload = 0,
}
type MobileDeviceEventHandler = (event: MobileDeviceEvent) => void
/**
* This identifier was the database name used in Standard Notes web/desktop.
*/
@@ -52,11 +62,16 @@ const showLoadFailForItemIds = (failedItemIds: string[]) => {
Alert.alert('Unable to load item(s)', text)
}
export class MobileDeviceInterface implements DeviceInterface {
export class MobileDevice implements MobileDeviceInterface {
environment: Environment.Mobile = Environment.Mobile
private eventObservers: MobileDeviceEventHandler[] = []
// eslint-disable-next-line @typescript-eslint/no-empty-function
deinit() {}
constructor(private stateObserverService?: AppStateObserverService) {}
deinit() {
this.stateObserverService?.deinit()
;(this.stateObserverService as unknown) = undefined
}
async setLegacyRawKeychainValue(value: LegacyRawKeychainValue): Promise<void> {
await Keychain.setKeys(value)
@@ -177,6 +192,14 @@ export class MobileDeviceInterface implements DeviceInterface {
}
}
hideMobileInterfaceFromScreenshots(): void {
hide()
}
stopHidingMobileInterfaceFromScreenshots(): void {
show()
}
async getAllRawStorageKeyValues() {
const keys = await AsyncStorage.getAllKeys()
return this.getRawStorageKeyValues(keys)
@@ -288,8 +311,67 @@ export class MobileDeviceInterface implements DeviceInterface {
}
}
getRawKeychainValue(): Promise<RawKeychainValue | null | undefined> {
return Keychain.getKeys()
async authenticateWithBiometrics() {
this.stateObserverService?.beginIgnoringStateChanges()
const result = await new Promise<boolean>((resolve) => {
if (Platform.OS === 'android') {
FingerprintScanner.authenticate({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore ts type does not exist for deviceCredentialAllowed
deviceCredentialAllowed: true,
description: 'Biometrics are required to access your notes.',
})
.then(() => {
FingerprintScanner.release()
resolve(true)
})
.catch((error) => {
FingerprintScanner.release()
if (error.name === 'DeviceLocked') {
Alert.alert('Unsuccessful', 'Authentication failed. Wait 30 seconds to try again.')
} else {
Alert.alert('Unsuccessful', 'Authentication failed. Tap to try again.')
}
resolve(false)
})
} else {
// iOS
FingerprintScanner.authenticate({
fallbackEnabled: true,
description: 'This is required to access your notes.',
})
.then(() => {
FingerprintScanner.release()
resolve(true)
})
.catch((error_1) => {
FingerprintScanner.release()
if (error_1.name !== 'SystemCancel') {
if (error_1.name !== 'UserCancel') {
Alert.alert('Unsuccessful')
} else {
Alert.alert('Unsuccessful', 'Authentication failed. Tap to try again.')
}
}
resolve(false)
})
}
})
this.stateObserverService?.stopIgnoringStateChanges()
return result
}
async getRawKeychainValue(): Promise<RawKeychainValue | undefined> {
const result = await Keychain.getKeys()
if (result === null) {
return undefined
}
return result
}
async clearRawKeychainValue(): Promise<void> {
@@ -328,7 +410,27 @@ export class MobileDeviceInterface implements DeviceInterface {
}
performSoftReset() {
SNReactNative.exitApp()
if (IsMobileWeb) {
this.notifyEvent(MobileDeviceEvent.RequestsWebViewReload)
} else {
SNReactNative.exitApp()
}
}
addMobileWebEventReceiver(handler: MobileDeviceEventHandler): () => void {
this.eventObservers.push(handler)
const thislessObservers = this.eventObservers
return () => {
removeFromArray(thislessObservers, handler)
}
}
private notifyEvent(event: MobileDeviceEvent): void {
for (const handler of this.eventObservers) {
handler(event)
}
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
+2 -1
View File
@@ -1,7 +1,8 @@
import { TEnvironment } from '@Root/App'
import { TEnvironment } from '@Root/NativeApp'
import VersionInfo from 'react-native-version-info'
export const IsDev = VersionInfo.bundleIdentifier?.includes('dev')
export const IsMobileWeb = IsDev
export function isNullOrUndefined(value: unknown) {
return value === null || value === undefined
+16
View File
@@ -0,0 +1,16 @@
import { navigationRef } from '@Lib/NavigationService'
import { NavigationContainer } from '@react-navigation/native'
import React from 'react'
import { MobileWebMainStackComponent } from './ModalStack'
const AppComponent: React.FC = () => {
return (
<NavigationContainer ref={navigationRef}>
<MobileWebMainStackComponent />
</NavigationContainer>
)
}
export const MobileWebApp = () => {
return <AppComponent />
}
+77 -24
View File
@@ -1,13 +1,50 @@
import { MobileDeviceInterface } from '@Lib/Interface'
import React, { useMemo, useRef } from 'react'
import { MobileDevice, MobileDeviceEvent } from '@Lib/Interface'
import { ReactNativeToWebEvent } from '@standardnotes/snjs'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Platform } from 'react-native'
import { WebView, WebViewMessageEvent } from 'react-native-webview'
import { AppStateObserverService } from './AppStateObserverService'
const LoggingEnabled = false
export const MobileWebAppContainer = () => {
const [identifier, setIdentifier] = useState(Math.random())
const destroyAndReload = useCallback(() => {
setIdentifier(Math.random())
}, [])
return <MobileWebAppContents key={`${identifier}`} destroyAndReload={destroyAndReload} />
}
const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => void }) => {
const webViewRef = useRef<WebView>(null)
const sourceUri = (Platform.OS === 'android' ? 'file:///android_asset/' : '') + 'Web.bundle/src/index.html'
const webViewRef = useRef<WebView>(null)
const stateService = useMemo(() => new AppStateObserverService(), [])
const device = useMemo(() => new MobileDevice(stateService), [stateService])
useEffect(() => {
const removeListener = stateService.addEventObserver((event: ReactNativeToWebEvent) => {
webViewRef.current?.postMessage(JSON.stringify({ reactNativeEvent: event, messageType: 'event' }))
})
return () => {
removeListener()
}
}, [webViewRef, stateService])
useEffect(() => {
const observer = device.addMobileWebEventReceiver((event) => {
if (event === MobileDeviceEvent.RequestsWebViewReload) {
destroyAndReload()
}
})
return () => {
observer()
}
}, [device, destroyAndReload])
const device = useMemo(() => new MobileDeviceInterface(), [])
const functions = Object.getOwnPropertyNames(Object.getPrototypeOf(device))
const baselineFunctions: Record<string, any> = {
@@ -28,7 +65,7 @@ export const MobileWebAppContainer = () => {
stringFunctions += `
${functionName}(...args) {
return this.sendMessage('${functionName}', args);
return this.askReactNativeToInvokeInterfaceMethod('${functionName}', args);
}
`
}
@@ -44,8 +81,8 @@ export const MobileWebAppContainer = () => {
setApplication() {}
sendMessage(functionName, args) {
return this.messageSender.sendMessage(functionName, args)
askReactNativeToInvokeInterfaceMethod(functionName, args) {
return this.messageSender.askReactNativeToInvokeInterfaceMethod(functionName, args)
}
${stringFunctions}
@@ -56,26 +93,18 @@ export const MobileWebAppContainer = () => {
class WebProcessMessageSender {
constructor() {
this.pendingMessages = []
window.addEventListener('message', this.handleMessageFromReactNative.bind(this))
document.addEventListener('message', this.handleMessageFromReactNative.bind(this))
}
handleMessageFromReactNative(event) {
const message = event.data
try {
const parsed = JSON.parse(message)
const { messageId, returnValue } = parsed
const pendingMessage = this.pendingMessages.find((m) => m.messageId === messageId)
pendingMessage.resolve(returnValue)
this.pendingMessages.splice(this.pendingMessages.indexOf(pendingMessage), 1)
} catch (error) {
console.log('Error parsing message from React Native', message, error)
}
handleReplyFromReactNative( messageId, returnValue) {
const pendingMessage = this.pendingMessages.find((m) => m.messageId === messageId)
pendingMessage.resolve(returnValue)
this.pendingMessages.splice(this.pendingMessages.indexOf(pendingMessage), 1)
}
sendMessage(functionName, args) {
askReactNativeToInvokeInterfaceMethod(functionName, args) {
const messageId = Math.random()
window.ReactNativeWebView.postMessage(JSON.stringify({ functionName: functionName, args: args, messageId }))
return new Promise((resolve) => {
this.pendingMessages.push({
messageId,
@@ -98,6 +127,25 @@ export const MobileWebAppContainer = () => {
const messageSender = new WebProcessMessageSender();
window.reactNativeDevice = new WebProcessDeviceInterface(messageSender);
const handleMessageFromReactNative = (event) => {
const message = event.data
try {
const parsed = JSON.parse(message)
const { messageId, returnValue, messageType } = parsed
if (messageType === 'reply') {
messageSender.handleReplyFromReactNative(messageId, returnValue)
}
} catch (error) {
console.log('Error parsing message from React Native', message, error)
}
}
window.addEventListener('message', handleMessageFromReactNative)
document.addEventListener('message', handleMessageFromReactNative)
true;
`
@@ -107,14 +155,18 @@ export const MobileWebAppContainer = () => {
const functionData = JSON.parse(message)
void onFunctionMessage(functionData.functionName, functionData.messageId, functionData.args)
} catch (error) {
console.log('onGeneralMessage', JSON.stringify(message))
if (LoggingEnabled) {
console.log('onGeneralMessage', JSON.stringify(message))
}
}
}
const onFunctionMessage = async (functionName: string, messageId: string, args: any) => {
const returnValue = await (device as any)[functionName](...args)
console.log(`Native device function ${functionName} called`)
webViewRef.current?.postMessage(JSON.stringify({ messageId, returnValue }))
if (LoggingEnabled) {
console.log(`Native device function ${functionName} called`)
}
webViewRef.current?.postMessage(JSON.stringify({ messageId, returnValue, messageType: 'reply' }))
}
/* eslint-disable @typescript-eslint/no-empty-function */
@@ -123,6 +175,7 @@ export const MobileWebAppContainer = () => {
ref={webViewRef}
source={{ uri: sourceUri }}
originWhitelist={['*']}
contentInset={{ top: 30, bottom: 10 }}
onLoad={() => {}}
onError={(err) => console.error('An error has occurred', err)}
onHttpError={() => console.error('An HTTP error occurred')}
+26 -2
View File
@@ -30,11 +30,11 @@ import React, { memo, useContext } from 'react'
import { Platform } from 'react-native'
import { HeaderButtons, Item } from 'react-navigation-header-buttons'
import { ThemeContext } from 'styled-components'
import { HeaderTitleParams, TEnvironment } from './App'
import { ApplicationContext } from './ApplicationContext'
import { AppStackComponent } from './AppStack'
import { HistoryStack } from './HistoryStack'
import { MobileWebAppContainer } from './MobileWebAppContainer'
import { HeaderTitleParams, TEnvironment } from './NativeApp'
export type ModalStackNavigatorParamList = {
AppStack: undefined
@@ -75,7 +75,31 @@ export type ModalStackNavigationProp<T extends keyof ModalStackNavigatorParamLis
const MainStack = createStackNavigator<ModalStackNavigatorParamList>()
export const MainStackComponent = ({ env }: { env: TEnvironment }) => {
export const MobileWebMainStackComponent = () => {
const MemoizedAppStackComponent = memo((props: ModalStackNavigationProp<'AppStack'>) => (
<AppStackComponent {...props} />
))
return (
<MainStack.Navigator
screenOptions={{
gestureEnabled: false,
presentation: 'modal',
}}
initialRouteName="AppStack"
>
<MainStack.Screen
name={'AppStack'}
options={{
headerShown: false,
}}
component={MemoizedAppStackComponent}
/>
</MainStack.Navigator>
)
}
export const NativeMainStackComponent = ({ env }: { env: TEnvironment }) => {
const application = useContext(ApplicationContext)
const theme = useContext(ThemeContext)
@@ -9,10 +9,9 @@ import { MobileThemeVariables } from '@Root/Style/Themes/styled-components'
import { ApplicationGroupEvent, DeinitMode, DeinitSource } from '@standardnotes/snjs'
import { ThemeService, ThemeServiceContext } from '@Style/ThemeService'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { StatusBar } from 'react-native'
import { ThemeProvider } from 'styled-components/native'
import { ApplicationContext } from './ApplicationContext'
import { MainStackComponent } from './ModalStack'
import { NativeMainStackComponent } from './ModalStack'
export type HeaderTitleParams = {
title?: string
@@ -107,13 +106,12 @@ const AppComponent: React.FC<{
}}
ref={navigationRef}
>
<StatusBar translucent />
{themeService.current && (
<>
<ThemeProvider theme={activeTheme}>
<ActionSheetProvider>
<ThemeServiceContext.Provider value={themeService.current}>
<MainStackComponent env={env} />
<NativeMainStackComponent env={env} />
</ThemeServiceContext.Provider>
</ActionSheetProvider>
<ToastWrapper />
@@ -124,7 +122,7 @@ const AppComponent: React.FC<{
)
}
export const App = (props: { env: TEnvironment }) => {
export const NativeApp = (props: { env: TEnvironment }) => {
const [application, setApplication] = useState<MobileApplication | undefined>()
const createNewAppGroup = useCallback(() => {
@@ -1,5 +1,5 @@
import { AppStateType, PasscodeKeyboardType } from '@Lib/ApplicationState'
import { MobileDeviceInterface } from '@Lib/Interface'
import { MobileDevice } from '@Lib/Interface'
import { HeaderHeightContext } from '@react-navigation/elements'
import { useFocusEffect } from '@react-navigation/native'
import { ApplicationContext } from '@Root/ApplicationContext'
@@ -164,7 +164,7 @@ export const Authenticate = ({
}, [])
const checkForBiometrics = useCallback(
async () => (application?.deviceInterface as MobileDeviceInterface).getDeviceBiometricsAvailability(),
async () => (application?.deviceInterface as MobileDevice).getDeviceBiometricsAvailability(),
[application],
)
@@ -8,10 +8,10 @@ import { SectionHeader } from '@Root/Components/SectionHeader'
import { TableSection } from '@Root/Components/TableSection'
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
import { ModalStackNavigationProp } from '@Root/ModalStack'
import { SCREEN_MANAGE_SESSIONS, SCREEN_SETTINGS, SCREEN_WEB_APP } from '@Root/Screens/screens'
import { SCREEN_MANAGE_SESSIONS, SCREEN_SETTINGS } from '@Root/Screens/screens'
import { ButtonType, PrefKey, StorageValueModes } from '@standardnotes/snjs'
import moment from 'moment'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import React, { useCallback, useMemo, useState } from 'react'
import { Platform } from 'react-native'
import DocumentPicker from 'react-native-document-picker'
import RNFS from 'react-native-fs'
@@ -183,22 +183,6 @@ export const OptionsSection = ({ title, encryptionAvailable }: Props) => {
)
}, [application.alertService])
const [shouldAlwaysOpenWebAppOnLaunch, setShouldAlwaysOpenWebAppOnLaunch] = useState(false)
useEffect(() => {
const getSetting = async () => {
const value = (await application.getValue(AlwaysOpenWebAppOnLaunchKey, StorageValueModes.Nonwrapped)) as
| boolean
| undefined
setShouldAlwaysOpenWebAppOnLaunch(value ?? false)
}
void getSetting()
}, [application])
const openWebApp = useCallback(() => {
navigation.push(SCREEN_WEB_APP)
}, [navigation])
return (
<TableSection>
<SectionHeader title={title} />
@@ -238,15 +222,19 @@ export const OptionsSection = ({ title, encryptionAvailable }: Props) => {
onPress={onExportPress}
/>
<ButtonCell testID="openWebApp" leftAligned title="Open Web App" onPress={() => openWebApp()} />
<SectionedAccessoryTableCell
onPress={() => {
const newValue = !shouldAlwaysOpenWebAppOnLaunch
setShouldAlwaysOpenWebAppOnLaunch(newValue)
void application.setValue(AlwaysOpenWebAppOnLaunchKey, newValue, StorageValueModes.Nonwrapped)
<ButtonCell
onPress={async () => {
const confirmationText =
'This will close the app and fully switch to the web view next time you open it. You will be able to switch back from the settings.'
if (
await application.alertService.confirm(confirmationText, 'Switch To Web View?', 'Switch', ButtonType.Info)
) {
application.setValue(AlwaysOpenWebAppOnLaunchKey, true, StorageValueModes.Nonwrapped)
setTimeout(() => application.deviceInterface.performSoftReset(), 1000)
}
}}
text="Always Open Web App On Launch"
selected={() => shouldAlwaysOpenWebAppOnLaunch}
title="Switch to Web View"
/>
{!signedIn && (
@@ -1,4 +1,4 @@
import { MobileDeviceInterface } from '@Lib/Interface'
import { MobileDevice } from '@Lib/Interface'
import { useFocusEffect, useNavigation } from '@react-navigation/native'
import { ApplicationContext } from '@Root/ApplicationContext'
import { ButtonCell } from '@Root/Components/ButtonCell'
@@ -53,7 +53,7 @@ export const SecuritySection = (props: Props) => {
void getHasBiometrics()
const hasBiometricsSupport = async () => {
const hasBiometricsAvailable = await (
application?.deviceInterface as MobileDeviceInterface
application?.deviceInterface as MobileDevice
).getDeviceBiometricsAvailability()
if (mounted) {
setSupportsBiometrics(hasBiometricsAvailable)
+44
View File
@@ -3,6 +3,50 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.3.57](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-20)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.56](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-20)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.55](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.54](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.53](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.52](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-18)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.51](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.50](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.49](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.48](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.47](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/releases
## [1.3.46](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/releases
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/releases",
"version": "1.3.46",
"version": "1.3.57",
"license": "AGPL-3.0-or-later",
"main": "dist/releases.json",
"types": "dist/index.d.ts",
+16
View File
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.21.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-20)
**Note:** Version bump only for package @standardnotes/services
# [1.21.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
### Features
* mobile web bridge ([#1597](https://github.com/standardnotes/app/issues/1597)) ([c4d7761](https://github.com/standardnotes/app/commit/c4d776149677269bc766f24da6adc5ec816dbcfb))
## [1.20.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
### Bug Fixes
* biometrics input on mobile webview challenge modal ([#1572](https://github.com/standardnotes/app/issues/1572)) ([3a15142](https://github.com/standardnotes/app/commit/3a15142940ef9391868442e1997c3d9d5eec33be))
## [1.20.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/services
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/services",
"version": "1.20.1",
"version": "1.21.1",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
@@ -42,6 +42,7 @@
"eslint": "^8.23.1",
"eslint-plugin-prettier": "*",
"jest": "^28.1.2",
"ts-jest": "^28.0.5"
"ts-jest": "^28.0.5",
"typescript": "*"
}
}
@@ -5,4 +5,8 @@ import { ApplicationInterface } from './ApplicationInterface'
export interface WebApplicationInterface extends ApplicationInterface {
notifyWebEvent(event: WebAppEvent, data?: unknown): void
getDesktopService(): DesktopManagerInterface | undefined
handleMobileEnteringBackgroundEvent(): Promise<void>
handleMobileGainingFocusEvent(): Promise<void>
handleMobileLosingFocusEvent(): Promise<void>
handleMobileResumingFromBackgroundEvent(): Promise<void>
}
@@ -7,6 +7,7 @@ export interface MobileDeviceInterface extends DeviceInterface {
getRawKeychainValue(): Promise<RawKeychainValue | undefined>
getDeviceBiometricsAvailability(): Promise<boolean>
setAndroidScreenshotPrivacy(enable: boolean): Promise<void>
getMobileScreenshotPrivacyEnabled(): Promise<boolean | undefined>
setMobileScreenshotPrivacyEnabled(isEnabled: boolean): Promise<void>
authenticateWithBiometrics(): Promise<boolean>
hideMobileInterfaceFromScreenshots(): void
stopHidingMobileInterfaceFromScreenshots(): void
}
+26
View File
@@ -3,6 +3,32 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.130.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-20)
**Note:** Version bump only for package @standardnotes/snjs
# [2.130.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
### Features
* mobile web bridge ([#1597](https://github.com/standardnotes/app/issues/1597)) ([c4d7761](https://github.com/standardnotes/app/commit/c4d776149677269bc766f24da6adc5ec816dbcfb))
## [2.129.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
### Bug Fixes
* **snjs:** pass unencrypted file size to comply with file service interface ([#1595](https://github.com/standardnotes/app/issues/1595)) ([038320b](https://github.com/standardnotes/app/commit/038320b2fda8348810db586c70b0998647519fd6))
## [2.129.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/snjs
# [2.129.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
### Features
* sharing subscriptions UI ([#1567](https://github.com/standardnotes/app/issues/1567)) ([2d0ee10](https://github.com/standardnotes/app/commit/2d0ee10226687df1d24926b9408ce270557a5b57))
## [2.128.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/snjs
+15 -1
View File
@@ -41,6 +41,7 @@ import {
FileService,
SubscriptionClientInterface,
SubscriptionManager,
StorageValueModes,
} from '@standardnotes/services'
import { FilesClientInterface } from '@standardnotes/files'
import { ComputePrivateWorkspaceIdentifier } from '@standardnotes/encryption'
@@ -60,6 +61,7 @@ import { SNLog } from '../Log'
import { Challenge, ChallengeResponse } from '../Services'
import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions'
import { ApplicationOptionsDefaults } from './Options/Defaults'
import { MobileUnlockTiming } from '@Lib/Services/Protection/MobileUnlockTiming'
/** How often to automatically sync, in milliseconds */
const DEFAULT_AUTO_SYNC_INTERVAL = 30_000
@@ -927,7 +929,7 @@ export class SNApplication
return this.deinit(this.getDeinitMode(), DeinitSource.Lock)
}
async setBiometricsTiming(timing: InternalServices.MobileUnlockTiming) {
async setBiometricsTiming(timing: MobileUnlockTiming) {
return this.protectionService.setBiometricsTiming(timing)
}
@@ -935,6 +937,18 @@ export class SNApplication
return this.protectionService.getMobileScreenshotPrivacyEnabled()
}
async getMobilePasscodeTiming(): Promise<MobileUnlockTiming | undefined> {
return this.getValue(StorageKey.MobilePasscodeTiming, StorageValueModes.Nonwrapped) as Promise<
MobileUnlockTiming | undefined
>
}
async getMobileBiometricsTiming(): Promise<MobileUnlockTiming | undefined> {
return this.getValue(StorageKey.MobileBiometricsTiming, StorageValueModes.Nonwrapped) as Promise<
MobileUnlockTiming | undefined
>
}
async setMobileScreenshotPrivacyEnabled(isEnabled: boolean) {
return this.protectionService.setMobileScreenshotPrivacyEnabled(isEnabled)
}
@@ -0,0 +1,6 @@
export enum ReactNativeToWebEvent {
EnteringBackground = 'EnteringBackground',
ResumingFromBackground = 'ResumingFromBackground',
GainingFocus = 'GainingFocus',
LosingFocus = 'LosingFocus',
}
+1
View File
@@ -2,3 +2,4 @@ export * from './IconsController'
export * from './NoteViewController'
export * from './FileViewController'
export * from './ItemGroupController'
export * from './ReactNativeToWebEvent'
@@ -0,0 +1,4 @@
export enum MobileUnlockTiming {
Immediately = 'immediately',
OnQuit = 'on-quit',
}
@@ -18,17 +18,13 @@ import {
} from '@standardnotes/services'
import { ProtectionsClientInterface } from './ClientInterface'
import { ContentType } from '@standardnotes/common'
import { MobileUnlockTiming } from './MobileUnlockTiming'
export enum ProtectionEvent {
UnprotectedSessionBegan = 'UnprotectedSessionBegan',
UnprotectedSessionExpired = 'UnprotectedSessionExpired',
}
export enum MobileUnlockTiming {
Immediately = 'immediately',
OnQuit = 'on-quit',
}
export const ProposedSecondsToDeferUILevelSessionExpirationDuringActiveInteraction = 30
export enum UnprotectedAccessSecondsDuration {
@@ -1,2 +1,3 @@
export * from './ClientInterface'
export * from './ProtectionService'
export * from './MobileUnlockTiming'
@@ -126,6 +126,8 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
}
private setSession(session: Session, persist = true): void {
this.httpService.setAuthorizationToken(session.authorizationValue)
this.apiService.setSession(session, persist)
}
@@ -621,8 +623,6 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
this.httpService.setHost(host)
this.httpService.setAuthorizationToken(session.authorizationValue)
await this.setSession(session)
this.webSocketsService.startWebSocketConnection(session.authorizationValue)
+1 -1
View File
@@ -53,7 +53,7 @@ describe('files', function () {
})
const uploadFile = async (fileService, buffer, name, ext, chunkSize) => {
const operation = await fileService.beginNewFileUpload()
const operation = await fileService.beginNewFileUpload(buffer.byteLength)
let chunkId = 1
for (let i = 0; i < buffer.length; i += chunkSize) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/snjs",
"version": "2.128.1",
"version": "2.130.1",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+12
View File
@@ -3,6 +3,18 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.2.6](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-20)
**Note:** Version bump only for package @standardnotes/ui-services
## [1.2.5](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
**Note:** Version bump only for package @standardnotes/ui-services
## [1.2.4](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/ui-services
## [1.2.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/ui-services
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/ui-services",
"version": "1.2.3",
"version": "1.2.6",
"engines": {
"node": ">=16.0.0 <17.0.0"
},
+66
View File
@@ -3,6 +3,72 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [3.50.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-20)
### Bug Fixes
* window size on mobile ([#1600](https://github.com/standardnotes/app/issues/1600)) ([dd5ca0c](https://github.com/standardnotes/app/commit/dd5ca0c28c683252fa3cc5eee3c27b981fbd7977))
## [3.50.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-20)
### Bug Fixes
* default to notes list on launch on mobile ([#1599](https://github.com/standardnotes/app/issues/1599)) ([571919c](https://github.com/standardnotes/app/commit/571919c9696212d4389692ccdf53e60a2bc06e16))
# [3.50.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
### Bug Fixes
* show warning on mobile webview before app quits ([#1598](https://github.com/standardnotes/app/issues/1598)) ([e3be17c](https://github.com/standardnotes/app/commit/e3be17c7f8345c1eed5b2cc6f98b38e24647b212))
### Features
* mobile web bridge ([#1597](https://github.com/standardnotes/app/issues/1597)) ([c4d7761](https://github.com/standardnotes/app/commit/c4d776149677269bc766f24da6adc5ec816dbcfb))
## [3.49.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
### Bug Fixes
* preferences dropdown contrast and color ([#1591](https://github.com/standardnotes/app/issues/1591)) ([ecaa2a6](https://github.com/standardnotes/app/commit/ecaa2a629f7c7bac58c26d7258469711b7c05785))
## [3.49.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-19)
**Note:** Version bump only for package @standardnotes/web
## [3.49.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-18)
### Bug Fixes
* move save status next to note actions on mobile ([#1590](https://github.com/standardnotes/app/issues/1590)) ([efe0c38](https://github.com/standardnotes/app/commit/efe0c38462bb5d75fc94a6ba966bc26a6973890f))
# [3.49.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
### Features
* add settings to fully switch between native/webview ([#1587](https://github.com/standardnotes/app/issues/1587)) ([85c90e0](https://github.com/standardnotes/app/commit/85c90e07fc8b1ce66eaa88df09f2728ac0fecf1c))
## [3.48.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
**Note:** Version bump only for package @standardnotes/web
## [3.48.2](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-16)
### Bug Fixes
* long titles on non-mobile view ([#1575](https://github.com/standardnotes/app/issues/1575)) ([7807fa3](https://github.com/standardnotes/app/commit/7807fa34f30f47ef3d7025bc4c804dbb9079c15a))
## [3.48.1](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
### Bug Fixes
* biometrics input on mobile webview challenge modal ([#1572](https://github.com/standardnotes/app/issues/1572)) ([3a15142](https://github.com/standardnotes/app/commit/3a15142940ef9391868442e1997c3d9d5eec33be))
# [3.48.0](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
### Features
* sharing subscriptions UI ([#1567](https://github.com/standardnotes/app/issues/1567)) ([2d0ee10](https://github.com/standardnotes/app/commit/2d0ee10226687df1d24926b9408ce270557a5b57))
## [3.47.3](https://github.com/standardnotes/app/compare/@standardnotes/[email protected]...@standardnotes/[email protected]) (2022-09-15)
**Note:** Version bump only for package @standardnotes/web
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/web",
"version": "3.47.3",
"version": "3.50.2",
"license": "AGPL-3.0-or-later",
"main": "dist/app.js",
"author": "Standard Notes.",
@@ -13,6 +13,7 @@
"clean": "rm -fr dist && rm -rf src/components",
"format": "prettier --write src/javascripts",
"lint": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint src/javascripts",
"lint:fix": "NODE_OPTIONS=\"--max-old-space-size=4096\" eslint src/javascripts --fix",
"start": "webpack-dev-server --config web.webpack.dev.js",
"start-secure": "yarn start --server-type https",
"test": "jest --config jest.config.js --coverage",
+4 -1
View File
@@ -66,7 +66,10 @@ const startApplication: StartApplication = async function startApplication(
root = createRoot(appendedRootNode)
disableIosTextFieldZoom()
document.documentElement.style.setProperty('--viewport-height', `${window.innerHeight}px`)
document.documentElement.style.setProperty(
'--viewport-height',
`${visualViewport ? visualViewport.height : window.innerHeight}px`,
)
root.render(
<ApplicationGroupView
@@ -17,12 +17,15 @@ import {
DecryptedItemInterface,
WebAppEvent,
WebApplicationInterface,
MobileDeviceInterface,
MobileUnlockTiming,
} from '@standardnotes/snjs'
import { makeObservable, observable } from 'mobx'
import { PanelResizedData } from '@/Types/PanelResizedData'
import { isDesktopApplication } from '@/Utils'
import { DesktopManager } from './Device/DesktopManager'
import { ArchiveManager, AutolockService, IOService, WebAlertService, ThemeManager } from '@standardnotes/ui-services'
import { MobileWebReceiver } from './MobileWebReceiver'
type WebServices = {
viewControllerManager: ViewControllerManager
@@ -41,6 +44,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter
public itemControllerGroup: ItemGroupController
public iconsController: IconsController
private onVisibilityChange: () => void
private mobileWebReceiver?: MobileWebReceiver
constructor(
deviceInterface: WebOrDesktopDevice,
@@ -70,6 +74,10 @@ export class WebApplication extends SNApplication implements WebApplicationInter
this.itemControllerGroup = new ItemGroupController(this)
this.iconsController = new IconsController()
if (this.isNativeMobileWeb()) {
this.mobileWebReceiver = new MobileWebReceiver(this)
}
this.onVisibilityChange = () => {
const visible = document.visibilityState === 'visible'
const event = visible ? WebAppEvent.WindowDidFocus : WebAppEvent.WindowDidBlur
@@ -101,6 +109,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter
this.itemControllerGroup.deinit()
;(this.itemControllerGroup as unknown) = undefined
;(this.mobileWebReceiver as unknown) = undefined
this.webEventObservers.length = 0
@@ -161,6 +170,13 @@ export class WebApplication extends SNApplication implements WebApplicationInter
return undefined
}
get mobileDevice(): MobileDeviceInterface {
if (!this.isNativeMobileWeb()) {
throw Error('Attempting to access device as mobile device on non mobile platform')
}
return this.deviceInterface as MobileDeviceInterface
}
public getThemeService() {
return this.webServices.themeService
}
@@ -203,4 +219,44 @@ export class WebApplication extends SNApplication implements WebApplicationInter
const currentValue = this.isGlobalSpellcheckEnabled()
return this.setPreference(PrefKey.EditorSpellcheck, !currentValue)
}
async handleMobileEnteringBackgroundEvent(): Promise<void> {
await this.lockApplicationAfterMobileEventIfApplicable()
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
async handleMobileGainingFocusEvent(): Promise<void> {}
async handleMobileLosingFocusEvent(): Promise<void> {
if (await this.getMobileScreenshotPrivacyEnabled()) {
this.mobileDevice.stopHidingMobileInterfaceFromScreenshots()
}
await this.lockApplicationAfterMobileEventIfApplicable()
}
async handleMobileResumingFromBackgroundEvent(): Promise<void> {
if (await this.getMobileScreenshotPrivacyEnabled()) {
this.mobileDevice.hideMobileInterfaceFromScreenshots()
}
}
private async lockApplicationAfterMobileEventIfApplicable(): Promise<void> {
const isLocked = await this.isLocked()
if (isLocked) {
return
}
const hasBiometrics = this.hasBiometrics()
const hasPasscode = this.hasPasscode()
const passcodeTiming = await this.getMobilePasscodeTiming()
const biometricsTiming = await this.getMobileBiometricsTiming()
const passcodeLockImmediately = hasPasscode && passcodeTiming === MobileUnlockTiming.Immediately
const biometricsLockImmediately = hasBiometrics && biometricsTiming === MobileUnlockTiming.Immediately
if (passcodeLockImmediately || biometricsLockImmediately) {
await this.lock()
}
}
}
@@ -0,0 +1,61 @@
import { ReactNativeToWebEvent, WebApplicationInterface } from '@standardnotes/snjs'
export class MobileWebReceiver {
constructor(private application: WebApplicationInterface) {
this.listenForNativeMobileEvents()
}
deinit() {
;(this.application as unknown) = undefined
window.removeEventListener('message', this.handleNativeMobileWindowMessage)
document.removeEventListener('message', this.handleNativeMobileWindowMessage as never)
}
listenForNativeMobileEvents() {
const iOSEventRecipient = window
const androidEventRecipient = document
iOSEventRecipient.addEventListener('message', this.handleNativeMobileWindowMessage)
androidEventRecipient.addEventListener('message', this.handleNativeMobileWindowMessage as never)
}
handleNativeMobileWindowMessage = (event: MessageEvent) => {
const nullOrigin = event.origin === '' || event.origin == null
if (!nullOrigin) {
return
}
const message = (event as MessageEvent).data
try {
const parsed = JSON.parse(message)
const { messageType, reactNativeEvent } = parsed
if (messageType === 'event' && reactNativeEvent) {
const nativeEvent = reactNativeEvent as ReactNativeToWebEvent
this.handleNativeEvent(nativeEvent)
}
} catch (error) {
console.log('Error parsing message from React Native', error)
}
}
handleNativeEvent(event: ReactNativeToWebEvent) {
switch (event) {
case ReactNativeToWebEvent.EnteringBackground:
void this.application.handleMobileEnteringBackgroundEvent()
break
case ReactNativeToWebEvent.GainingFocus:
void this.application.handleMobileGainingFocusEvent()
break
case ReactNativeToWebEvent.LosingFocus:
void this.application.handleMobileLosingFocusEvent()
break
case ReactNativeToWebEvent.ResumingFromBackground:
void this.application.handleMobileResumingFromBackgroundEvent()
break
default:
break
}
}
}
@@ -45,7 +45,9 @@ const WorkspaceSwitcherMenu: FunctionComponent<Props> = ({
const signoutAll = useCallback(async () => {
const confirmed = await viewControllerManager.application.alertService.confirm(
'Are you sure you want to sign out of all workspaces on this device?',
`Are you sure you want to sign out of all workspaces on this device?${
viewControllerManager.application.isNativeMobileWeb() && '<b> Your app will quit after sign out completes.</b>'
}`,
undefined,
'Sign out all',
ButtonType.Danger,
@@ -60,6 +62,47 @@ const WorkspaceSwitcherMenu: FunctionComponent<Props> = ({
viewControllerManager.accountMenuController.setSigningOut(true)
}, [viewControllerManager])
const activateWorkspace = useCallback(
async (descriptor: ApplicationDescriptor) => {
if (viewControllerManager.application.isNativeMobileWeb()) {
const confirmed = await viewControllerManager.application.alertService.confirm(
'<b>The app needs to be restarted to activate the workspace</b>',
undefined,
'Quit app and activate workspace',
ButtonType.Danger,
)
if (confirmed) {
void mainApplicationGroup.unloadCurrentAndActivateDescriptor(descriptor)
}
return
}
void mainApplicationGroup.unloadCurrentAndActivateDescriptor(descriptor)
},
[mainApplicationGroup, viewControllerManager.application],
)
const addAnotherWorkspace = useCallback(async () => {
if (viewControllerManager.application.isNativeMobileWeb()) {
const confirmed = await viewControllerManager.application.alertService.confirm(
'<b>The app needs to be restarted to add another workspace</b>',
undefined,
'Quit app and add new workspace',
ButtonType.Danger,
)
if (confirmed) {
void mainApplicationGroup.unloadCurrentAndCreateNewDescriptor()
}
return
}
void mainApplicationGroup.unloadCurrentAndCreateNewDescriptor()
}, [mainApplicationGroup, viewControllerManager.application])
return (
<Menu a11yLabel="Workspace switcher menu" className="px-0 focus:shadow-none" isOpen={isOpen}>
{applicationDescriptors.map((descriptor) => (
@@ -68,18 +111,13 @@ const WorkspaceSwitcherMenu: FunctionComponent<Props> = ({
descriptor={descriptor}
hideOptions={hideWorkspaceOptions}
onDelete={destroyWorkspace}
onClick={() => void mainApplicationGroup.unloadCurrentAndActivateDescriptor(descriptor)}
onClick={() => activateWorkspace(descriptor)}
renameDescriptor={(label: string) => mainApplicationGroup.renameDescriptor(descriptor, label)}
/>
))}
<MenuItemSeparator />
<MenuItem
type={MenuItemType.IconButton}
onClick={() => {
void mainApplicationGroup.unloadCurrentAndCreateNewDescriptor()
}}
>
<MenuItem type={MenuItemType.IconButton} onClick={addAnotherWorkspace}>
<Icon type="user-add" className="mr-2 text-neutral" />
Add another workspace
</MenuItem>
@@ -5,6 +5,7 @@ import {
Challenge,
ChallengePrompt,
ChallengeReason,
ChallengeValidation,
ChallengeValue,
removeFromArray,
} from '@standardnotes/snjs'
@@ -17,6 +18,7 @@ import LockscreenWorkspaceSwitcher from './LockscreenWorkspaceSwitcher'
import { ApplicationGroup } from '@/Application/ApplicationGroup'
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import { ChallengeModalValues } from './ChallengeModalValues'
import { InputValue } from './InputValue'
type Props = {
application: WebApplication
@@ -64,9 +66,11 @@ const ChallengeModal: FunctionComponent<Props> = ({
const [isProcessing, setIsProcessing] = useState(false)
const [, setProcessingPrompts] = useState<ChallengePrompt[]>([])
const [bypassModalFocusLock, setBypassModalFocusLock] = useState(false)
const shouldShowForgotPasscode = [ChallengeReason.ApplicationUnlock, ChallengeReason.Migration].includes(
challenge.reason,
)
const shouldShowWorkspaceSwitcher = challenge.reason === ChallengeReason.ApplicationUnlock
const submit = useCallback(() => {
@@ -106,7 +110,7 @@ const ChallengeModal: FunctionComponent<Props> = ({
}, [application, challenge, isProcessing, isSubmitting, values])
const onValueChange = useCallback(
(value: string | number, prompt: ChallengePrompt) => {
(value: InputValue['value'], prompt: ChallengePrompt) => {
const newValues = { ...values }
newValues[prompt.id].invalid = false
newValues[prompt.id].value = value
@@ -169,6 +173,17 @@ const ChallengeModal: FunctionComponent<Props> = ({
}
}, [application, challenge, onDismiss])
const biometricPrompt = challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.Biometric)
const hasOnlyBiometricPrompt = challenge.prompts.length === 1 && !!biometricPrompt
const hasBiometricPromptValue = biometricPrompt && values[biometricPrompt.id].value
useEffect(() => {
const shouldAutoSubmit = hasOnlyBiometricPrompt && hasBiometricPromptValue
if (shouldAutoSubmit) {
submit()
}
}, [hasBiometricPromptValue, hasOnlyBiometricPrompt, submit])
if (!challenge.prompts) {
return null
}
@@ -201,11 +216,9 @@ const ChallengeModal: FunctionComponent<Props> = ({
)}
<ProtectedIllustration className="mb-4 h-30 w-30" />
<div className="mb-3 max-w-76 text-center text-lg font-bold">{challenge.heading}</div>
{challenge.subheading && (
<div className="break-word mb-4 max-w-76 text-center text-sm">{challenge.subheading}</div>
)}
<form
className="flex min-w-76 flex-col items-center"
onSubmit={(e) => {
@@ -215,6 +228,7 @@ const ChallengeModal: FunctionComponent<Props> = ({
>
{challenge.prompts.map((prompt, index) => (
<ChallengeModalPrompt
application={application}
key={prompt.id}
prompt={prompt}
values={values}
@@ -1,25 +1,50 @@
import { ChallengePrompt, ChallengeValidation, ProtectionSessionDurations } from '@standardnotes/snjs'
import {
ChallengePrompt,
ChallengeValidation,
MobileDeviceInterface,
ProtectionSessionDurations,
} from '@standardnotes/snjs'
import { FunctionComponent, useEffect, useRef } from 'react'
import DecoratedInput from '@/Components/Input/DecoratedInput'
import DecoratedPasswordInput from '@/Components/Input/DecoratedPasswordInput'
import { ChallengeModalValues } from './ChallengeModalValues'
import Button from '../Button/Button'
import { WebApplication } from '@/Application/Application'
import { InputValue } from './InputValue'
type Props = {
application: WebApplication
prompt: ChallengePrompt
values: ChallengeModalValues
index: number
onValueChange: (value: string | number, prompt: ChallengePrompt) => void
onValueChange: (value: InputValue['value'], prompt: ChallengePrompt) => void
isInvalid: boolean
}
const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index, onValueChange, isInvalid }) => {
const ChallengeModalPrompt: FunctionComponent<Props> = ({
application,
prompt,
values,
index,
onValueChange,
isInvalid,
}) => {
const inputRef = useRef<HTMLInputElement>(null)
const biometricsButtonRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (index === 0) {
const isNotFirstPrompt = index !== 0
if (isNotFirstPrompt) {
return
}
if (prompt.validation === ChallengeValidation.Biometric) {
biometricsButtonRef.current?.click()
} else {
inputRef.current?.focus()
}
}, [index])
}, [index, prompt.validation])
useEffect(() => {
if (isInvalid) {
@@ -61,6 +86,22 @@ const ChallengeModalPrompt: FunctionComponent<Props> = ({ prompt, values, index,
})}
</div>
</div>
) : prompt.validation === ChallengeValidation.Biometric ? (
<div className="min-w-76">
<Button
primary
fullWidth
onClick={async () => {
const authenticated = await (
application.deviceInterface as MobileDeviceInterface
).authenticateWithBiometrics()
onValueChange(authenticated, prompt)
}}
ref={biometricsButtonRef}
>
Tap to use biometrics
</Button>
</div>
) : prompt.secureTextEntry ? (
<DecoratedPasswordInput
ref={inputRef}
@@ -200,7 +200,6 @@ const ComponentView: FunctionComponent<IProps> = ({ application, onLoad, compone
{error === ComponentViewerError.MissingUrl && <UrlMissing componentName={component.displayName} />}
{component.uuid && isComponentValid && (
<iframe
className="min-h-[40rem]"
ref={iframeRef}
onLoad={onIframeLoad}
data-component-viewer-id={componentViewer.identifier}
@@ -41,7 +41,15 @@ const ConfirmSignoutModal: FunctionComponent<Props> = ({ application, viewContro
<AlertDialogLabel className="sk-h3 sk-panel-section-title">Sign out workspace?</AlertDialogLabel>
<AlertDialogDescription className="sk-panel-row">
<div>
<p className="text-foreground">{STRING_SIGN_OUT_CONFIRMATION}</p>
<p className="text-foreground">
{STRING_SIGN_OUT_CONFIRMATION}
{application.isNativeMobileWeb() && (
<div className="font-bold">
<br />
Your app will quit after sign out completes.
</div>
)}
</p>
{showWorkspaceWarning && (
<>
<br />
@@ -14,6 +14,7 @@ type DropdownProps = {
value: string
onChange: (value: string, item: DropdownItem) => void
disabled?: boolean
className?: string
}
type ListboxButtonProps = DropdownItem & {
@@ -41,7 +42,7 @@ const CustomDropdownButton: FunctionComponent<ListboxButtonProps> = ({
</>
)
const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, onChange, disabled }) => {
const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, onChange, disabled, className }) => {
const labelId = `${id}-label`
const handleChange = (value: string) => {
@@ -51,7 +52,7 @@ const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, o
}
return (
<>
<div className={className}>
<VisuallyHidden id={labelId}>{label}</VisuallyHidden>
<ListboxInput value={value} onChange={handleChange} aria-labelledby={labelId} disabled={disabled}>
<StyledListboxButton
@@ -85,7 +86,7 @@ const Dropdown: FunctionComponent<DropdownProps> = ({ id, label, items, value, o
</div>
</ListboxPopover>
</ListboxInput>
</>
</div>
)
}
@@ -39,7 +39,7 @@ const FileViewWithoutProtection = ({ application, viewControllerManager, file }:
<div className="flex h-8 items-center justify-between">
<div className="flex flex-grow items-center">
<MobileItemsListButton />
<div className="title overflow-auto">
<div className="title flex-grow overflow-auto">
<input
className="input text-lg"
id={ElementIds.FileTitleEditor}
@@ -916,7 +916,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
<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 />
<div className="title overflow-auto">
<div className="title flex-grow overflow-auto">
<input
className="input text-lg"
disabled={this.state.noteLocked}
@@ -932,12 +932,7 @@ class NoteView extends PureComponent<NoteViewProps, State> {
/>
</div>
</div>
<div
className={classNames(
'flex flex-col flex-wrap items-start gap-3 md:flex-col-reverse md:items-end',
'xl:flex-row xl:flex-nowrap xl:items-center',
)}
>
<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">
@@ -8,6 +8,7 @@ import Subscription from './Subscription/Subscription'
import SignOutWrapper from './SignOutView'
import FilesSection from './Files'
import PreferencesPane from '../../PreferencesComponents/PreferencesPane'
import SubscriptionSharing from './SubscriptionSharing/SubscriptionSharing'
type Props = {
application: WebApplication
@@ -25,6 +26,7 @@ const AccountPreferences = ({ application, viewControllerManager }: Props) => (
</>
)}
<Subscription application={application} viewControllerManager={viewControllerManager} />
<SubscriptionSharing application={application} viewControllerManager={viewControllerManager} />
{application.hasAccount() && viewControllerManager.featuresController.hasFiles && (
<FilesSection application={application} />
)}
@@ -0,0 +1,84 @@
import { useState } from 'react'
import { observer } from 'mobx-react-lite'
import { InvitationStatus, Uuid } from '@standardnotes/snjs'
import { SubtitleLight, Text } from '@/Components/Preferences/PreferencesComponents/Content'
import { SubscriptionController } from '@/Controllers/Subscription/SubscriptionController'
import Button from '@/Components/Button/Button'
import { WebApplication } from '@/Application/Application'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
type Props = {
subscriptionState: SubscriptionController
application: WebApplication
}
const InvitationsList = ({ subscriptionState, application }: Props) => {
const [lockContinue, setLockContinue] = useState(false)
const { usedInvitationsCount, subscriptionInvitations } = subscriptionState
const activeSubscriptions = subscriptionInvitations?.filter((invitation) =>
[InvitationStatus.Sent, InvitationStatus.Accepted].includes(invitation.status),
)
const inActiveSubscriptions = subscriptionInvitations?.filter((invitation) =>
[InvitationStatus.Declined, InvitationStatus.Canceled].includes(invitation.status),
)
const handleCancel = async (invitationUuid: Uuid) => {
if (lockContinue) {
application.alertService.alert('Cancelation already in progress.').catch(console.error)
return
}
setLockContinue(true)
const success = await subscriptionState.cancelSubscriptionInvitation(invitationUuid)
setLockContinue(false)
if (!success) {
application.alertService
.alert('Could not cancel invitation. Please try again or contact support if the issue persists.')
.catch(console.error)
}
}
if (usedInvitationsCount === 0) {
return <Text className="mt-1 mb-3">Make your first subscription invitation below.</Text>
}
return (
<div>
<SubtitleLight className="mb-2 text-info">Active Invitations:</SubtitleLight>
{activeSubscriptions?.map((invitation) => (
<div key={invitation.uuid} className="mt-1 mb-4">
<Text>
{invitation.inviteeIdentifier} <span className="text-info">({invitation.status})</span>
</Text>
{invitation.status !== InvitationStatus.Canceled && (
<Button className="mt-2 min-w-20" label="Cancel" onClick={() => handleCancel(invitation.uuid)} />
)}
</div>
))}
{!!inActiveSubscriptions?.length && (
<>
<SubtitleLight className="mb-2 text-info">Inactive Invitations:</SubtitleLight>
<div>
{inActiveSubscriptions?.map((invitation) => (
<div key={invitation.uuid} className="mb-3 first:mt-2">
<Text className="mt-1">
{invitation.inviteeIdentifier} <span className="text-info">({invitation.status})</span>
</Text>
</div>
))}
</div>
</>
)}
{!subscriptionState.allInvitationsUsed && <HorizontalSeparator classes="my-4" />}
</div>
)
}
export default observer(InvitationsList)
@@ -0,0 +1,128 @@
import { FunctionComponent, useState } from 'react'
import ModalDialog from '@/Components/Shared/ModalDialog'
import ModalDialogButtons from '@/Components/Shared/ModalDialogButtons'
import ModalDialogDescription from '@/Components/Shared/ModalDialogDescription'
import ModalDialogLabel from '@/Components/Shared/ModalDialogLabel'
import Button from '@/Components/Button/Button'
import { WebApplication } from '@/Application/Application'
import { isEmailValid } from '@/Utils'
import { SubscriptionController } from '@/Controllers/Subscription/SubscriptionController'
import InviteForm from './InviteForm'
import InviteSuccess from './InviteSuccess'
enum SubmitButtonTitles {
Default = 'Send Invitation',
Sending = 'Sending...',
Finish = 'Finish',
}
enum Steps {
InitialStep,
FinishStep,
}
type Props = {
onCloseDialog: () => void
application: WebApplication
subscriptionState: SubscriptionController
}
const Invite: FunctionComponent<Props> = ({ onCloseDialog, application, subscriptionState }) => {
const [submitButtonTitle, setSubmitButtonTitle] = useState(SubmitButtonTitles.Default)
const [inviteeEmail, setInviteeEmail] = useState('')
const [isContinuing, setIsContinuing] = useState(false)
const [lockContinue, setLockContinue] = useState(false)
const [currentStep, setCurrentStep] = useState(Steps.InitialStep)
const validateInviteeEmail = async () => {
if (!isEmailValid(inviteeEmail)) {
application.alertService
.alert('The email you entered has an invalid format. Please review your input and try again.')
.catch(console.error)
return false
}
return true
}
const handleDialogClose = () => {
if (lockContinue) {
application.alertService.alert('Cannot close window until pending tasks are complete.').catch(console.error)
} else {
onCloseDialog()
}
}
const resetProgressState = () => {
setSubmitButtonTitle(SubmitButtonTitles.Default)
setIsContinuing(false)
}
const processInvite = async () => {
setLockContinue(true)
const success = await subscriptionState.sendSubscriptionInvitation(inviteeEmail)
setLockContinue(false)
return success
}
const handleSubmit = async () => {
if (lockContinue || isContinuing) {
return
}
if (currentStep === Steps.FinishStep) {
handleDialogClose()
return
}
setIsContinuing(true)
setSubmitButtonTitle(SubmitButtonTitles.Sending)
const valid = await validateInviteeEmail()
if (!valid) {
resetProgressState()
return
}
const success = await processInvite()
if (!success) {
application.alertService
.alert('We could not send the invitation. Please try again or contact support if the issue persists.')
.catch(console.error)
resetProgressState()
return
}
setIsContinuing(false)
setSubmitButtonTitle(SubmitButtonTitles.Finish)
setCurrentStep(Steps.FinishStep)
}
return (
<div>
<ModalDialog>
<ModalDialogLabel closeDialog={handleDialogClose}>Invite</ModalDialogLabel>
<ModalDialogDescription className="flex flex-row items-center px-4.5">
{currentStep === Steps.InitialStep && <InviteForm setInviteeEmail={setInviteeEmail} />}
{currentStep === Steps.FinishStep && <InviteSuccess />}
</ModalDialogDescription>
<ModalDialogButtons className="px-4.5">
<Button className="min-w-20" primary label={submitButtonTitle} onClick={handleSubmit} />
</ModalDialogButtons>
</ModalDialog>
</div>
)
}
export default Invite
@@ -0,0 +1,28 @@
import { Dispatch, FunctionComponent, SetStateAction } from 'react'
import DecoratedInput from '@/Components/Input/DecoratedInput'
type Props = {
setInviteeEmail: Dispatch<SetStateAction<string>>
}
const InviteForm: FunctionComponent<Props> = ({ setInviteeEmail }) => {
return (
<div className="flex w-full flex-col">
<div className="mb-3">
<label className="mb-1 block" htmlFor="invite-email-input">
Invitee Email:
</label>
<DecoratedInput
type="email"
id="invite-email-input"
onChange={(email) => {
setInviteeEmail(email)
}}
/>
</div>
</div>
)
}
export default InviteForm
@@ -0,0 +1,11 @@
import { FunctionComponent } from 'react'
const InviteSuccess: FunctionComponent = () => {
return (
<div>
<div className={'mb-2 font-bold text-info'}>Your invitation has been successfully sent.</div>
</div>
)
}
export default InviteSuccess
@@ -0,0 +1,47 @@
import { FunctionComponent, useState } from 'react'
import { LinkButton, Text } from '@/Components/Preferences/PreferencesComponents/Content'
import Button from '@/Components/Button/Button'
import { WebApplication } from '@/Application/Application'
import { loadPurchaseFlowUrl } from '@/Components/PurchaseFlow/PurchaseFlowFunctions'
type Props = {
application: WebApplication
}
const NoProSubscription: FunctionComponent<Props> = ({ application }) => {
const [isLoadingPurchaseFlow, setIsLoadingPurchaseFlow] = useState(false)
const [purchaseFlowError, setPurchaseFlowError] = useState<string | undefined>(undefined)
const onPurchaseClick = async () => {
const errorMessage = 'There was an error when attempting to redirect you to the subscription page.'
setIsLoadingPurchaseFlow(true)
try {
if (!(await loadPurchaseFlowUrl(application))) {
setPurchaseFlowError(errorMessage)
}
} catch (e) {
setPurchaseFlowError(errorMessage)
} finally {
setIsLoadingPurchaseFlow(false)
}
}
return (
<>
<Text>
Subscription sharing is available only on the <span className="font-bold">Professional</span> plan. Please
upgrade in order to share subscription.
</Text>
{isLoadingPurchaseFlow && <Text>Redirecting you to the subscription page...</Text>}
{purchaseFlowError && <Text className="text-danger">{purchaseFlowError}</Text>}
<div className="flex">
<LinkButton className="mt-3 mr-3 min-w-20" label="Learn More" link={window.plansUrl as string} />
{application.hasAccount() && (
<Button className="mt-3 min-w-20" primary label="Upgrade" onClick={onPurchaseClick} />
)}
</div>
</>
)
}
export default NoProSubscription
@@ -0,0 +1,18 @@
import { SubscriptionController } from '@/Controllers/Subscription/SubscriptionController'
import { observer } from 'mobx-react-lite'
import { Text } from '@/Components/Preferences/PreferencesComponents/Content'
type Props = { subscriptionState: SubscriptionController }
const SharingStatusText = ({ subscriptionState }: Props) => {
const { usedInvitationsCount, allowedInvitationsCount } = subscriptionState
return (
<Text className="mt-1">
You have have used <span className="font-bold">{usedInvitationsCount}</span> out of {allowedInvitationsCount}{' '}
subscription invitations.
</Text>
)
}
export default observer(SharingStatusText)
@@ -0,0 +1,63 @@
import { FeatureStatus, FeatureIdentifier } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useState } from 'react'
import { Title } from '@/Components/Preferences/PreferencesComponents/Content'
import { WebApplication } from '@/Application/Application'
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
import PreferencesGroup from '@/Components/Preferences/PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '@/Components/Preferences/PreferencesComponents/PreferencesSegment'
import HorizontalSeparator from '@/Components/Shared/HorizontalSeparator'
import NoProSubscription from './NoProSubscription'
import InvitationsList from './InvitationsList'
import Invite from './Invite/Invite'
import Button from '@/Components/Button/Button'
import SharingStatusText from './SharingStatusText'
type Props = {
application: WebApplication
viewControllerManager: ViewControllerManager
}
const SubscriptionSharing: FunctionComponent<Props> = ({ application, viewControllerManager }: Props) => {
const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false)
const subscriptionState = viewControllerManager.subscriptionController
const isSubscriptionSharingFeatureAvailable =
application.features.getFeatureStatus(FeatureIdentifier.TwoFactorAuth) === FeatureStatus.Entitled
return (
<PreferencesGroup>
<PreferencesSegment>
<div className="flex flex-row items-center">
<div className="flex flex-grow flex-col">
<Title className="mb-2">Subscription Sharing</Title>
{isSubscriptionSharingFeatureAvailable ? (
<div>
<SharingStatusText subscriptionState={subscriptionState} />
<HorizontalSeparator classes="my-4" />
<InvitationsList subscriptionState={subscriptionState} application={application} />
{!subscriptionState.allInvitationsUsed && (
<Button className="min-w-20" label="Invite" onClick={() => setIsInviteDialogOpen(true)} />
)}
{isInviteDialogOpen && (
<Invite
onCloseDialog={() => setIsInviteDialogOpen(false)}
application={application}
subscriptionState={subscriptionState}
/>
)}
</div>
) : (
<NoProSubscription application={application} />
)}
</div>
</div>
</PreferencesSegment>
</PreferencesGroup>
)
}
export default observer(SubscriptionSharing)
@@ -1,6 +1,13 @@
import Dropdown from '@/Components/Dropdown/Dropdown'
import { DropdownItem } from '@/Components/Dropdown/DropdownItem'
import { FeatureIdentifier, PrefKey, ComponentArea, ComponentMutator, SNComponent } from '@standardnotes/snjs'
import {
FeatureIdentifier,
PrefKey,
ComponentArea,
ComponentMutator,
SNComponent,
StorageValueModes,
} from '@standardnotes/snjs'
import { Subtitle, Text, Title } from '@/Components/Preferences/PreferencesComponents/Content'
import { WebApplication } from '@/Application/Application'
import { FunctionComponent, useEffect, useState } from 'react'
@@ -9,6 +16,7 @@ import Switch from '@/Components/Switch/Switch'
import { PLAIN_EDITOR_NAME } from '@/Constants/Constants'
import PreferencesGroup from '../../PreferencesComponents/PreferencesGroup'
import PreferencesSegment from '../../PreferencesComponents/PreferencesSegment'
import Button from '@/Components/Button/Button'
type Props = {
application: WebApplication
@@ -43,6 +51,8 @@ const getDefaultEditor = (application: WebApplication) => {
return application.componentManager.componentsForArea(ComponentArea.Editor).filter((e) => e.isDefaultEditor())[0]
}
const AlwaysOpenWebAppOnLaunchKey = 'AlwaysOpenWebAppOnLaunch'
const Defaults: FunctionComponent<Props> = ({ application }) => {
const [editorItems, setEditorItems] = useState<DropdownItem[]>([])
const [defaultEditorValue, setDefaultEditorValue] = useState(
@@ -102,10 +112,30 @@ const Defaults: FunctionComponent<Props> = ({ application }) => {
}
}
const switchToNativeView = async () => {
application.setValue(AlwaysOpenWebAppOnLaunchKey, false, StorageValueModes.Nonwrapped)
setTimeout(() => {
application.deviceInterface.performSoftReset()
}, 1000)
}
return (
<PreferencesGroup>
<PreferencesSegment>
<Title>Defaults</Title>
{application.isNativeMobileWeb() && (
<>
<div className="flex flex-col">
<Subtitle>Switch to Native View</Subtitle>
<Text>
This will close the app and fully switch to the native view next time you open it. You will be able to
switch back from the settings.
</Text>
<Button className="mt-3 min-w-20" label="Switch" onClick={switchToNativeView} />
</div>
<HorizontalSeparator classes="my-4" />
</>
)}
<div>
<Subtitle>Default Note Type</Subtitle>
<Text>New notes will be created using this type.</Text>
@@ -17,9 +17,6 @@ interface SecurityProps extends MfaProps {
application: WebApplication
}
const SHOW_MULTITASKING_PRIVACY = false
const SHOW_BIOMETRICS_LOCK = false
const Security: FunctionComponent<SecurityProps> = (props) => {
const isNativeMobileWeb = props.application.isNativeMobileWeb()
@@ -31,9 +28,9 @@ const Security: FunctionComponent<SecurityProps> = (props) => {
)}
<Protections application={props.application} />
<TwoFactorAuthWrapper mfaProvider={props.mfaProvider} userProvider={props.userProvider} />
{SHOW_MULTITASKING_PRIVACY && isNativeMobileWeb && <MultitaskingPrivacy application={props.application} />}
{isNativeMobileWeb && <MultitaskingPrivacy application={props.application} />}
<PasscodeLock viewControllerManager={props.viewControllerManager} application={props.application} />
{SHOW_BIOMETRICS_LOCK && isNativeMobileWeb && <BiometricsLock application={props.application} />}
{isNativeMobileWeb && <BiometricsLock application={props.application} />}
{props.application.getUser() && <Privacy application={props.application} />}
</PreferencesPane>
)
@@ -1,29 +1,25 @@
import { classNames } from '@/Utils/ConcatenateClassNames'
import { FunctionComponent, ReactNode } from 'react'
type ChildrenProp = {
children: ReactNode
}
export const Title: FunctionComponent<ChildrenProp> = ({ children }) => (
<>
<h2 className="m-0 mb-1 text-lg font-bold text-info md:text-base">{children}</h2>
</>
)
type Props = {
className?: string
} & ChildrenProp
children: ReactNode
}
export const Subtitle: FunctionComponent<Props> = ({ children, className = '' }) => (
<h4 className={`m-0 mb-1 text-sm font-medium ${className}`}>{children}</h4>
export const Title: FunctionComponent<Props> = ({ children, className }) => (
<h2 className={classNames('m-0 mb-1 text-lg font-bold text-info md:text-base', className)}>{children}</h2>
)
export const SubtitleLight: FunctionComponent<Props> = ({ children, className = '' }) => (
<h4 className={`m-0 mb-1 text-sm font-normal ${className}`}>{children}</h4>
export const Subtitle: FunctionComponent<Props> = ({ children, className }) => (
<h4 className={classNames('m-0 mb-1 text-sm font-medium', className)}>{children}</h4>
)
export const Text: FunctionComponent<Props> = ({ children, className = '' }) => (
<p className={`${className} text-sm md:text-xs`}>{children}</p>
export const SubtitleLight: FunctionComponent<Props> = ({ children, className }) => (
<h4 className={classNames('m-0 mb-1 text-sm font-normal', className)}>{children}</h4>
)
export const Text: FunctionComponent<Props> = ({ children, className }) => (
<p className={classNames('text-sm md:text-xs', className)}>{children}</p>
)
const buttonClasses =
@@ -1,5 +1,6 @@
import { observer } from 'mobx-react-lite'
import { FunctionComponent, useMemo } from 'react'
import styled from 'styled-components'
import Dropdown from '../Dropdown/Dropdown'
import { DropdownItem } from '../Dropdown/DropdownItem'
import PreferencesMenuItem from './PreferencesComponents/MenuItem'
@@ -9,6 +10,19 @@ type Props = {
menu: PreferencesMenu
}
const StyledDropdown = styled(Dropdown)`
[data-reach-listbox-button] {
background: var(--sn-stylekit-contrast-background-color);
color: var(--sn-stylekit-info-color);
font-weight: bold;
padding: 0.55rem 0.875rem;
[data-reach-listbox-arrow] svg {
fill: var(--sn-stylekit-info-color);
}
}
`
const PreferencesMenuView: FunctionComponent<Props> = ({ menu }) => {
const { selectedPaneId, selectPane, menuItems } = menu
@@ -23,7 +37,7 @@ const PreferencesMenuView: FunctionComponent<Props> = ({ menu }) => {
)
return (
<div className="px-5 pt-2 pb-4 md:px-0 md:py-0">
<div className="border-t border-border bg-default px-5 pt-2 pb-6 md:border-0 md:bg-transparent md:px-0 md:py-0">
<div className="hidden min-w-55 flex-col overflow-y-auto px-3 py-6 md:flex">
{menuItems.map((pref) => (
<PreferencesMenuItem
@@ -37,7 +51,7 @@ const PreferencesMenuView: FunctionComponent<Props> = ({ menu }) => {
))}
</div>
<div className="md:hidden">
<Dropdown
<StyledDropdown
id="preferences-menu"
items={dropdownMenuItems}
label="Preferences Menu"
@@ -1,4 +1,5 @@
import { ElementIds } from '@/Constants/ElementIDs'
import { isMobileScreen } from '@/Utils'
import { useEffect, ReactNode, useMemo, createContext, useCallback, useContext, useState } from 'react'
import { AppPaneId } from './AppPaneMetadata'
@@ -24,8 +25,12 @@ type Props = {
}
const ResponsivePaneProvider = ({ children }: Props) => {
const [currentSelectedPane, setCurrentSelectedPane] = useState<AppPaneId>(AppPaneId.Editor)
const [previousSelectedPane, setPreviousSelectedPane] = useState<AppPaneId>(AppPaneId.Editor)
const [currentSelectedPane, setCurrentSelectedPane] = useState<AppPaneId>(
isMobileScreen() ? AppPaneId.Items : AppPaneId.Editor,
)
const [previousSelectedPane, setPreviousSelectedPane] = useState<AppPaneId>(
isMobileScreen() ? AppPaneId.Items : AppPaneId.Editor,
)
const toggleAppPane = useCallback(
(paneId: AppPaneId) => {
@@ -4,6 +4,10 @@ import {
ClientDisplayableError,
convertTimestampToMilliseconds,
InternalEventBus,
Invitation,
InvitationStatus,
SubscriptionClientInterface,
Uuid,
} from '@standardnotes/snjs'
import { action, computed, makeObservable, observable } from 'mobx'
import { WebApplication } from '../../Application/Application'
@@ -12,28 +16,40 @@ import { AvailableSubscriptions } from './AvailableSubscriptionsType'
import { Subscription } from './SubscriptionType'
export class SubscriptionController extends AbstractViewController {
private readonly ALLOWED_SUBSCRIPTION_INVITATIONS = 5
userSubscription: Subscription | undefined = undefined
availableSubscriptions: AvailableSubscriptions | undefined = undefined
subscriptionInvitations: Invitation[] | undefined = undefined
override deinit() {
super.deinit()
;(this.userSubscription as unknown) = undefined
;(this.availableSubscriptions as unknown) = undefined
;(this.subscriptionInvitations as unknown) = undefined
destroyAllObjectProperties(this)
}
constructor(application: WebApplication, eventBus: InternalEventBus) {
constructor(
application: WebApplication,
eventBus: InternalEventBus,
private subscriptionManager: SubscriptionClientInterface,
) {
super(application, eventBus)
makeObservable(this, {
userSubscription: observable,
availableSubscriptions: observable,
subscriptionInvitations: observable,
userSubscriptionName: computed,
userSubscriptionExpirationDate: computed,
isUserSubscriptionExpired: computed,
isUserSubscriptionCanceled: computed,
usedInvitationsCount: computed,
allowedInvitationsCount: computed,
allInvitationsUsed: computed,
setUserSubscription: action,
setAvailableSubscriptions: action,
@@ -43,6 +59,7 @@ export class SubscriptionController extends AbstractViewController {
application.addEventObserver(async () => {
if (application.hasAccount()) {
this.getSubscriptionInfo().catch(console.error)
this.reloadSubscriptionInvitations().catch(console.error)
}
}, ApplicationEvent.Launched),
)
@@ -50,12 +67,14 @@ export class SubscriptionController extends AbstractViewController {
this.disposers.push(
application.addEventObserver(async () => {
this.getSubscriptionInfo().catch(console.error)
this.reloadSubscriptionInvitations().catch(console.error)
}, ApplicationEvent.SignedIn),
)
this.disposers.push(
application.addEventObserver(async () => {
this.getSubscriptionInfo().catch(console.error)
this.reloadSubscriptionInvitations().catch(console.error)
}, ApplicationEvent.UserRolesChanged),
)
}
@@ -91,6 +110,22 @@ export class SubscriptionController extends AbstractViewController {
return Boolean(this.userSubscription?.cancelled)
}
get usedInvitationsCount(): number {
return (
this.subscriptionInvitations?.filter((invitation) =>
[InvitationStatus.Accepted, InvitationStatus.Sent].includes(invitation.status),
).length ?? 0
)
}
get allowedInvitationsCount(): number {
return this.ALLOWED_SUBSCRIPTION_INVITATIONS
}
get allInvitationsUsed(): boolean {
return this.usedInvitationsCount === this.ALLOWED_SUBSCRIPTION_INVITATIONS
}
public setUserSubscription(subscription: Subscription): void {
this.userSubscription = subscription
}
@@ -99,6 +134,26 @@ export class SubscriptionController extends AbstractViewController {
this.availableSubscriptions = subscriptions
}
async sendSubscriptionInvitation(inviteeEmail: string): Promise<boolean> {
const success = await this.subscriptionManager.inviteToSubscription(inviteeEmail)
if (success) {
await this.reloadSubscriptionInvitations()
}
return success
}
async cancelSubscriptionInvitation(invitationUuid: Uuid): Promise<boolean> {
const success = await this.subscriptionManager.cancelInvitation(invitationUuid)
if (success) {
await this.reloadSubscriptionInvitations()
}
return success
}
private async getAvailableSubscriptions() {
try {
const subscriptions = await this.application.getAvailableSubscriptions()
@@ -125,4 +180,8 @@ export class SubscriptionController extends AbstractViewController {
await this.getSubscription()
await this.getAvailableSubscriptions()
}
private async reloadSubscriptionInvitations(): Promise<void> {
this.subscriptionInvitations = await this.subscriptionManager.listSubscriptionInvitations()
}
}
@@ -9,6 +9,7 @@ import {
InternalEventBus,
ItemCounterInterface,
ItemCounter,
SubscriptionClientInterface,
} from '@standardnotes/snjs'
import { action, makeObservable, observable } from 'mobx'
import { ActionsMenuController } from './ActionsMenuController'
@@ -60,12 +61,15 @@ export class ViewControllerManager {
private appEventObserverRemovers: (() => void)[] = []
private eventBus: InternalEventBus
private itemCounter: ItemCounterInterface
private subscriptionManager: SubscriptionClientInterface
constructor(public application: WebApplication, private device: WebOrDesktopDeviceInterface) {
this.eventBus = new InternalEventBus()
this.itemCounter = new ItemCounter()
this.subscriptionManager = application.subscriptions
this.selectionController = new SelectedItemsController(application, this.eventBus)
this.noteTagsController = new NoteTagsController(application, this.eventBus)
@@ -102,7 +106,7 @@ export class ViewControllerManager {
this.accountMenuController = new AccountMenuController(application, this.eventBus, this.itemCounter)
this.subscriptionController = new SubscriptionController(application, this.eventBus)
this.subscriptionController = new SubscriptionController(application, this.eventBus, this.subscriptionManager)
this.purchaseFlowController = new PurchaseFlowController(application, this.eventBus)
+2
View File
@@ -6397,6 +6397,7 @@ __metadata:
jest: ^28.1.2
reflect-metadata: ^0.1.13
ts-jest: ^28.0.5
typescript: "*"
languageName: unknown
linkType: soft
@@ -7370,6 +7371,7 @@ __metadata:
jest: ^28.1.2
reflect-metadata: ^0.1.13
ts-jest: ^28.0.5
typescript: "*"
languageName: unknown
linkType: soft