feat: mobile app package (#1075)
This commit is contained in:
70
packages/mobile/src/Screens/Compose/ComponentView.styled.ts
Normal file
70
packages/mobile/src/Screens/Compose/ComponentView.styled.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ICON_ALERT, ICON_LOCK } from '@Style/Icons'
|
||||
import { ThemeService } from '@Style/ThemeService'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import Icon from 'react-native-vector-icons/Ionicons'
|
||||
import WebView from 'react-native-webview'
|
||||
import styled, { css } from 'styled-components/native'
|
||||
|
||||
export const FlexContainer = styled(SafeAreaView).attrs(() => ({
|
||||
edges: ['bottom'],
|
||||
}))`
|
||||
flex: 1;
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
`
|
||||
|
||||
export const LockedContainer = styled.View`
|
||||
justify-content: flex-start;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background-color: ${({ theme }) => theme.stylekitWarningColor};
|
||||
border-bottom-color: ${({ theme }) => theme.stylekitBorderColor};
|
||||
border-bottom-width: 1px;
|
||||
`
|
||||
export const LockedText = styled.Text`
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
padding-left: 10px;
|
||||
`
|
||||
|
||||
export const StyledWebview = styled(WebView)<{ showWebView: boolean }>`
|
||||
flex: 1;
|
||||
background-color: transparent;
|
||||
opacity: 0.99;
|
||||
min-height: 1px;
|
||||
${({ showWebView }) =>
|
||||
!showWebView &&
|
||||
css`
|
||||
display: none;
|
||||
`};
|
||||
`
|
||||
|
||||
export const StyledIcon = styled(Icon).attrs(({ theme }) => ({
|
||||
color: theme.stylekitBackgroundColor,
|
||||
size: 16,
|
||||
name: ThemeService.nameForIcon(ICON_LOCK),
|
||||
}))``
|
||||
|
||||
export const DeprecatedContainer = styled.View`
|
||||
justify-content: flex-start;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background-color: ${({ theme }) => theme.stylekitWarningColor};
|
||||
border-bottom-color: ${({ theme }) => theme.stylekitBorderColor};
|
||||
border-bottom-width: 1px;
|
||||
`
|
||||
|
||||
export const DeprecatedText = styled.Text`
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
padding-left: 10px;
|
||||
`
|
||||
|
||||
export const DeprecatedIcon = styled(Icon).attrs(({ theme }) => ({
|
||||
color: theme.stylekitBackgroundColor,
|
||||
size: 16,
|
||||
name: ThemeService.nameForIcon(ICON_ALERT),
|
||||
}))``
|
||||
315
packages/mobile/src/Screens/Compose/ComponentView.tsx
Normal file
315
packages/mobile/src/Screens/Compose/ComponentView.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import { ComponentLoadingError } from '@Lib/ComponentManager'
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native'
|
||||
import { ApplicationContext } from '@Root/ApplicationContext'
|
||||
import { AppStackNavigationProp } from '@Root/AppStack'
|
||||
import { SCREEN_NOTES } from '@Root/Screens/screens'
|
||||
import { ButtonType, ComponentViewer, PrefKey } from '@standardnotes/snjs'
|
||||
import { ThemeServiceContext } from '@Style/ThemeService'
|
||||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
import { WebView } from 'react-native-webview'
|
||||
import {
|
||||
OnShouldStartLoadWithRequest,
|
||||
WebViewErrorEvent,
|
||||
WebViewMessageEvent,
|
||||
} from 'react-native-webview/lib/WebViewTypes'
|
||||
import {
|
||||
DeprecatedContainer,
|
||||
DeprecatedIcon,
|
||||
DeprecatedText,
|
||||
FlexContainer,
|
||||
LockedContainer,
|
||||
LockedText,
|
||||
StyledIcon,
|
||||
StyledWebview,
|
||||
} from './ComponentView.styled'
|
||||
|
||||
type Props = {
|
||||
componentViewer: ComponentViewer
|
||||
onLoadEnd: () => void
|
||||
onLoadStart: () => void
|
||||
onLoadError: (error: ComponentLoadingError, desc?: string) => void
|
||||
onDownloadEditorStart: () => void
|
||||
onDownloadEditorEnd: () => void
|
||||
}
|
||||
|
||||
const log = (message?: any, ...optionalParams: any[]) => {
|
||||
const LOGGING_ENABLED = false
|
||||
if (LOGGING_ENABLED) {
|
||||
console.log(message, optionalParams, '\n\n')
|
||||
console.log('\n\n')
|
||||
}
|
||||
}
|
||||
|
||||
/** On Android, webview.onShouldStartLoadWithRequest is not called by react-native-webview*/
|
||||
const SupportsShouldLoadRequestHandler = Platform.OS === 'ios'
|
||||
|
||||
export const ComponentView = ({
|
||||
onLoadEnd,
|
||||
onLoadError,
|
||||
onLoadStart,
|
||||
onDownloadEditorStart,
|
||||
onDownloadEditorEnd,
|
||||
componentViewer,
|
||||
}: Props) => {
|
||||
// Context
|
||||
const application = useContext(ApplicationContext)
|
||||
const themeService = useContext(ThemeServiceContext)
|
||||
|
||||
// State
|
||||
const [showWebView, setShowWebView] = useState<boolean>(true)
|
||||
const [requiresLocalEditor, setRequiresLocalEditor] = useState<boolean>(false)
|
||||
const [localEditorReady, setLocalEditorReady] = useState<boolean>(false)
|
||||
|
||||
// Ref
|
||||
const didLoadRootUrl = useRef<boolean>(false)
|
||||
const webViewRef = useRef<WebView>(null)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
const navigation = useNavigation<AppStackNavigationProp<typeof SCREEN_NOTES>['navigation']>()
|
||||
|
||||
useEffect(() => {
|
||||
const removeBlurScreenListener = navigation.addListener('blur', () => {
|
||||
setShowWebView(false)
|
||||
})
|
||||
|
||||
return removeBlurScreenListener
|
||||
}, [navigation])
|
||||
|
||||
useFocusEffect(() => {
|
||||
setShowWebView(true)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const warnIfUnsupportedEditors = async () => {
|
||||
let platformVersionRequirements
|
||||
|
||||
switch (Platform.OS) {
|
||||
case 'ios':
|
||||
if (parseInt(Platform.Version.toString(), 10) < 11) {
|
||||
// WKWebView has issues on iOS < 11
|
||||
platformVersionRequirements = 'iOS 11 or greater'
|
||||
}
|
||||
break
|
||||
case 'android':
|
||||
if (Platform.Version <= 23) {
|
||||
/**
|
||||
* postMessage doesn't work on Android <= 6 (API version 23)
|
||||
* https://github.com/facebook/react-native/issues/11594
|
||||
*/
|
||||
platformVersionRequirements = 'Android 7.0 or greater'
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (!platformVersionRequirements) {
|
||||
return
|
||||
}
|
||||
|
||||
const doNotShowAgainUnsupportedEditors = application
|
||||
?.getLocalPreferences()
|
||||
.getValue(PrefKey.MobileDoNotShowAgainUnsupportedEditors, false)
|
||||
|
||||
if (!doNotShowAgainUnsupportedEditors) {
|
||||
const alertText =
|
||||
`Web editors require ${platformVersionRequirements}. ` +
|
||||
'Your version does not support web editors. ' +
|
||||
'Changes you make may not be properly saved. Please switch to the Plain Editor for the best experience.'
|
||||
|
||||
const confirmed = await application?.alertService?.confirm(
|
||||
alertText,
|
||||
'Editors Not Supported',
|
||||
"Don't show again",
|
||||
ButtonType.Info,
|
||||
'OK',
|
||||
)
|
||||
|
||||
if (confirmed) {
|
||||
void application?.getLocalPreferences().setUserPrefValue(PrefKey.MobileDoNotShowAgainUnsupportedEditors, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void warnIfUnsupportedEditors()
|
||||
}, [application])
|
||||
|
||||
const onLoadErrorHandler = useCallback(
|
||||
(error?: WebViewErrorEvent) => {
|
||||
log('On load error', error)
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
onLoadError(ComponentLoadingError.Unknown, error?.nativeEvent?.description)
|
||||
},
|
||||
[onLoadError, timeoutRef],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const componentManager = application!.mobileComponentManager
|
||||
const component = componentViewer.component
|
||||
const isDownloadable = componentManager.isComponentDownloadable(component)
|
||||
setRequiresLocalEditor(isDownloadable)
|
||||
|
||||
if (isDownloadable) {
|
||||
const asyncFunc = async () => {
|
||||
if (await componentManager.doesComponentNeedDownload(component)) {
|
||||
onDownloadEditorStart()
|
||||
const error = await componentManager.downloadComponentOffline(component)
|
||||
log('Download component error', error)
|
||||
onDownloadEditorEnd()
|
||||
if (error) {
|
||||
onLoadError(error)
|
||||
}
|
||||
}
|
||||
setLocalEditorReady(true)
|
||||
}
|
||||
void asyncFunc()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const onMessage = (event: WebViewMessageEvent) => {
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(event.nativeEvent.data)
|
||||
} catch (e) {
|
||||
log('Message is not valid JSON, returning')
|
||||
return
|
||||
}
|
||||
componentViewer?.handleMessage(data)
|
||||
}
|
||||
|
||||
const onFrameLoad = useCallback(() => {
|
||||
log('Iframe did load', webViewRef.current?.props.source)
|
||||
|
||||
/**
|
||||
* We have no way of knowing if the webview load is successful or not. We
|
||||
* have to wait to see if the error event is fired. Looking at the code,
|
||||
* the error event is fired right after this, so we can wait just a few ms
|
||||
* to see if the error event is fired before registering the component
|
||||
* window. Otherwise, on error, this component will be dealloced, and a
|
||||
* pending postMessage will cause a memory leak crash on Android in the
|
||||
* form of "react native attempt to invoke virtual method
|
||||
* double java.lang.double.doublevalue() on a null object reference"
|
||||
*/
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
|
||||
if (didLoadRootUrl.current === true || !SupportsShouldLoadRequestHandler) {
|
||||
log('Setting component viewer webview')
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
componentViewer?.setWindow(webViewRef.current as unknown as Window)
|
||||
}, 1)
|
||||
/**
|
||||
* The parent will remove their loading screen on load end. We want to
|
||||
* delay this to avoid flicker that may result if using a dark theme.
|
||||
* This delay will allow editor to load its theme.
|
||||
*/
|
||||
const isDarkTheme = themeService?.isLikelyUsingDarkColorTheme()
|
||||
const delayToAvoidFlicker = isDarkTheme ? 50 : 0
|
||||
setTimeout(() => {
|
||||
onLoadEnd()
|
||||
}, delayToAvoidFlicker)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const onLoadStartHandler = () => {
|
||||
onLoadStart()
|
||||
}
|
||||
|
||||
const onShouldStartLoadWithRequest: OnShouldStartLoadWithRequest = request => {
|
||||
log('Setting last iframe URL to', request.url)
|
||||
/** The first request can typically be 'about:blank', which we want to ignore */
|
||||
if (!didLoadRootUrl.current) {
|
||||
didLoadRootUrl.current = request.url === componentViewer.url!
|
||||
}
|
||||
/**
|
||||
* We want to handle link clicks within an editor by opening the browser
|
||||
* instead of loading inline. On iOS, onShouldStartLoadWithRequest is
|
||||
* called for all requests including the initial request to load the editor.
|
||||
* On iOS, clicks in the editors have a navigationType of 'click', but on
|
||||
* Android, this is not the case (no navigationType).
|
||||
* However, on Android, this function is not called for the initial request.
|
||||
* So that might be one way to determine if this request is a click or the
|
||||
* actual editor load request. But I don't think it's safe to rely on this
|
||||
* being the case in the future. So on Android, we'll handle url loads only
|
||||
* if the url isn't equal to the editor url.
|
||||
*/
|
||||
|
||||
if (
|
||||
(Platform.OS === 'ios' && request.navigationType === 'click') ||
|
||||
(Platform.OS === 'android' && request.url !== componentViewer.url!)
|
||||
) {
|
||||
application!.deviceInterface!.openUrl(request.url)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const defaultInjectedJavaScript = () => {
|
||||
return `(function() {
|
||||
window.parent.postMessage = function(data) {
|
||||
window.parent.ReactNativeWebView.postMessage(data);
|
||||
};
|
||||
const meta = document.createElement('meta');
|
||||
meta.setAttribute('content', 'width=device-width, initial-scale=1, user-scalable=no');
|
||||
meta.setAttribute('name', 'viewport');
|
||||
document.getElementsByTagName('head')[0].appendChild(meta);
|
||||
return true;
|
||||
})()`
|
||||
}
|
||||
|
||||
const deprecationMessage = componentViewer.component.deprecationMessage
|
||||
|
||||
const renderWebview = !requiresLocalEditor || localEditorReady
|
||||
|
||||
return (
|
||||
<FlexContainer>
|
||||
{componentViewer.component.isExpired && (
|
||||
<LockedContainer>
|
||||
<StyledIcon />
|
||||
<LockedText>
|
||||
Subscription expired. Editors are in a read-only state. To edit immediately, please switch to the Plain
|
||||
Editor.
|
||||
</LockedText>
|
||||
</LockedContainer>
|
||||
)}
|
||||
|
||||
{componentViewer.component.isDeprecated && (
|
||||
<DeprecatedContainer>
|
||||
<DeprecatedIcon />
|
||||
<DeprecatedText>{deprecationMessage || 'This extension is deprecated.'}</DeprecatedText>
|
||||
</DeprecatedContainer>
|
||||
)}
|
||||
|
||||
{renderWebview && (
|
||||
<StyledWebview
|
||||
showWebView={showWebView}
|
||||
source={{ uri: componentViewer.url! }}
|
||||
key={componentViewer.component.uuid}
|
||||
ref={webViewRef}
|
||||
/**
|
||||
* onLoad and onLoadEnd seem to be the same exact thing, except
|
||||
* that when an error occurs, onLoadEnd is called twice, whereas
|
||||
* onLoad is called once (what we want)
|
||||
*/
|
||||
onLoad={onFrameLoad}
|
||||
onLoadStart={onLoadStartHandler}
|
||||
onError={onLoadErrorHandler}
|
||||
onHttpError={() => onLoadErrorHandler()}
|
||||
onMessage={onMessage}
|
||||
hideKeyboardAccessoryView={true}
|
||||
setSupportMultipleWindows={false}
|
||||
onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
|
||||
cacheEnabled={true}
|
||||
autoManageStatusBarEnabled={false /* To prevent StatusBar from changing colors when focusing */}
|
||||
injectedJavaScript={defaultInjectedJavaScript()}
|
||||
onContentProcessDidTerminate={() => onLoadErrorHandler()}
|
||||
/>
|
||||
)}
|
||||
</FlexContainer>
|
||||
)
|
||||
}
|
||||
121
packages/mobile/src/Screens/Compose/Compose.styled.ts
Normal file
121
packages/mobile/src/Screens/Compose/Compose.styled.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import SNTextView from '@standardnotes/react-native-textview'
|
||||
import React, { ComponentProps } from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
import styled, { css } from 'styled-components/native'
|
||||
|
||||
const PADDING = 14
|
||||
const NOTE_TITLE_HEIGHT = 50
|
||||
|
||||
export const Container = styled.View`
|
||||
flex: 1;
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
`
|
||||
export const LockedContainer = styled.View`
|
||||
justify-content: flex-start;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: ${PADDING}px;
|
||||
padding: 8px;
|
||||
background-color: ${({ theme }) => theme.stylekitNeutralColor};
|
||||
border-bottom-color: ${({ theme }) => theme.stylekitBorderColor};
|
||||
border-bottom-width: 1px;
|
||||
`
|
||||
export const LockedText = styled.Text`
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
padding-left: 10px;
|
||||
padding-right: 100px;
|
||||
`
|
||||
export const WebViewReloadButton = styled.TouchableOpacity`
|
||||
position: absolute;
|
||||
right: ${PADDING}px;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`
|
||||
export const WebViewReloadButtonText = styled.Text`
|
||||
color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
`
|
||||
export const NoteTitleInput = styled.TextInput`
|
||||
font-weight: ${Platform.OS === 'ios' ? 600 : 'bold'};
|
||||
font-size: ${Platform.OS === 'ios' ? 17 : 18}px;
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
height: ${NOTE_TITLE_HEIGHT}px;
|
||||
border-bottom-color: ${({ theme }) => theme.stylekitBorderColor};
|
||||
border-bottom-width: 1px;
|
||||
padding-top: ${Platform.OS === 'ios' ? 5 : 12}px;
|
||||
padding-left: ${PADDING}px;
|
||||
padding-right: ${PADDING}px;
|
||||
`
|
||||
export const LoadingWebViewContainer = styled.View<{ locked?: boolean }>`
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
top: ${({ locked }) => (locked ? NOTE_TITLE_HEIGHT + 26 : NOTE_TITLE_HEIGHT)}px;
|
||||
bottom: 0px;
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
`
|
||||
export const LoadingText = styled.Text`
|
||||
padding-left: 0px;
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
opacity: 0.7;
|
||||
margin-top: 5px;
|
||||
`
|
||||
export const ContentContainer = styled.View`
|
||||
flex-grow: 1;
|
||||
`
|
||||
export const TextContainer = styled.View`
|
||||
flex: 1;
|
||||
`
|
||||
export const StyledKeyboardAvoidngView = styled.KeyboardAvoidingView`
|
||||
flex: 1;
|
||||
${({ theme }) => theme.stylekitBackgroundColor};
|
||||
`
|
||||
|
||||
const StyledTextViewComponent = styled(SNTextView)<{ errorState: boolean }>`
|
||||
padding-top: 10px;
|
||||
color: ${({ theme }) => theme.stylekitForegroundColor};
|
||||
padding-left: ${({ theme }) => theme.paddingLeft - (Platform.OS === 'ios' ? 5 : 0)}px;
|
||||
padding-right: ${({ theme }) => theme.paddingLeft - (Platform.OS === 'ios' ? 5 : 0)}px;
|
||||
padding-bottom: ${({ errorState }) => (errorState ? 36 : 10)}px;
|
||||
${Platform.OS === 'ios' &&
|
||||
css`
|
||||
height: 96%;
|
||||
`}
|
||||
${Platform.OS === 'android' &&
|
||||
css`
|
||||
flex: 1;
|
||||
`}
|
||||
background-color: ${({ theme }) => theme.stylekitBackgroundColor};
|
||||
/* ${Platform.OS === 'ios' && 'padding-bottom: 10px'}; */
|
||||
`
|
||||
|
||||
export const StyledTextView = React.memo(
|
||||
StyledTextViewComponent,
|
||||
(newProps: ComponentProps<typeof SNTextView>, prevProps: ComponentProps<typeof SNTextView>) => {
|
||||
if (
|
||||
newProps.value !== prevProps.value ||
|
||||
newProps.selectionColor !== prevProps.selectionColor ||
|
||||
newProps.handlesColor !== prevProps.handlesColor ||
|
||||
newProps.autoFocus !== prevProps.autoFocus ||
|
||||
newProps.editable !== prevProps.editable ||
|
||||
newProps.keyboardDismissMode !== prevProps.keyboardDismissMode ||
|
||||
newProps.keyboardAppearance !== prevProps.keyboardAppearance ||
|
||||
newProps.testID !== prevProps.testID ||
|
||||
newProps.multiline !== prevProps.multiline
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
)
|
||||
587
packages/mobile/src/Screens/Compose/Compose.tsx
Normal file
587
packages/mobile/src/Screens/Compose/Compose.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
import { AppStateEventType } from '@Lib/ApplicationState'
|
||||
import { ComponentLoadingError, ComponentManager } from '@Lib/ComponentManager'
|
||||
import { isNullOrUndefined } from '@Lib/Utils'
|
||||
import { ApplicationContext, SafeApplicationContext } from '@Root/ApplicationContext'
|
||||
import { AppStackNavigationProp } from '@Root/AppStack'
|
||||
import { SCREEN_COMPOSE } from '@Root/Screens/screens'
|
||||
import SNTextView from '@standardnotes/react-native-textview'
|
||||
import {
|
||||
ApplicationEvent,
|
||||
ComponentMutator,
|
||||
ComponentViewer,
|
||||
ContentType,
|
||||
isPayloadSourceInternalChange,
|
||||
isPayloadSourceRetrieved,
|
||||
ItemMutator,
|
||||
NoteMutator,
|
||||
NoteViewController,
|
||||
PayloadEmitSource,
|
||||
SNComponent,
|
||||
UuidString,
|
||||
} from '@standardnotes/snjs'
|
||||
import { ICON_ALERT, ICON_LOCK } from '@Style/Icons'
|
||||
import { ThemeService, ThemeServiceContext } from '@Style/ThemeService'
|
||||
import { lighten } from '@Style/Utils'
|
||||
import React, { createRef } from 'react'
|
||||
import { Keyboard, Platform, View } from 'react-native'
|
||||
import Icon from 'react-native-vector-icons/Ionicons'
|
||||
import { ThemeContext } from 'styled-components'
|
||||
import { ComponentView } from './ComponentView'
|
||||
import {
|
||||
Container,
|
||||
LoadingText,
|
||||
LoadingWebViewContainer,
|
||||
LockedContainer,
|
||||
LockedText,
|
||||
NoteTitleInput,
|
||||
StyledTextView,
|
||||
TextContainer,
|
||||
WebViewReloadButton,
|
||||
WebViewReloadButtonText,
|
||||
} from './Compose.styled'
|
||||
|
||||
const NOTE_PREVIEW_CHAR_LIMIT = 80
|
||||
const MINIMUM_STATUS_DURATION = 400
|
||||
const SAVE_TIMEOUT_DEBOUNCE = 250
|
||||
const SAVE_TIMEOUT_NO_DEBOUNCE = 100
|
||||
|
||||
type State = {
|
||||
title: string
|
||||
text: string
|
||||
saveError: boolean
|
||||
webViewError?: ComponentLoadingError
|
||||
webViewErrorDesc?: string
|
||||
loadingWebview: boolean
|
||||
downloadingEditor: boolean
|
||||
componentViewer?: ComponentViewer
|
||||
}
|
||||
|
||||
type PropsWhenNavigating = AppStackNavigationProp<typeof SCREEN_COMPOSE>
|
||||
|
||||
type PropsWhenRenderingDirectly = {
|
||||
noteUuid: UuidString
|
||||
}
|
||||
|
||||
const EditingIsDisabledText = 'This note has editing disabled. Please enable editing on this note to make changes.'
|
||||
|
||||
export class Compose extends React.Component<PropsWhenNavigating | PropsWhenRenderingDirectly, State> {
|
||||
static override contextType = ApplicationContext
|
||||
override context: React.ContextType<typeof ApplicationContext>
|
||||
editor: NoteViewController
|
||||
editorViewRef: React.RefObject<SNTextView> = createRef()
|
||||
saveTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
alreadySaved = false
|
||||
statusTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
downloadingMessageTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
removeNoteInnerValueObserver?: () => void
|
||||
removeComponentsObserver?: () => void
|
||||
removeStreamComponents?: () => void
|
||||
removeStateEventObserver?: () => void
|
||||
removeAppEventObserver?: () => void
|
||||
removeComponentHandler?: () => void
|
||||
|
||||
constructor(
|
||||
props: PropsWhenNavigating | PropsWhenRenderingDirectly,
|
||||
context: React.ContextType<typeof SafeApplicationContext>,
|
||||
) {
|
||||
super(props)
|
||||
this.context = context
|
||||
|
||||
const noteUuid = 'noteUuid' in props ? props.noteUuid : props.route.params.noteUuid
|
||||
const editor = this.context.editorGroup.noteControllers.find(c => c.note.uuid === noteUuid)
|
||||
if (!editor) {
|
||||
throw 'Unable to to find note controller'
|
||||
}
|
||||
|
||||
this.editor = editor
|
||||
|
||||
this.state = {
|
||||
title: this.editor.note.title,
|
||||
text: this.editor.note.text,
|
||||
componentViewer: undefined,
|
||||
saveError: false,
|
||||
webViewError: undefined,
|
||||
loadingWebview: false,
|
||||
downloadingEditor: false,
|
||||
}
|
||||
}
|
||||
|
||||
override componentDidMount() {
|
||||
this.removeNoteInnerValueObserver = this.editor.addNoteInnerValueChangeObserver((note, source) => {
|
||||
if (isPayloadSourceRetrieved(source)) {
|
||||
this.setState({
|
||||
title: note.title,
|
||||
text: note.text,
|
||||
})
|
||||
}
|
||||
|
||||
const isTemplateNoteInsertedToBeInteractableWithEditor = source === PayloadEmitSource.LocalInserted && note.dirty
|
||||
if (isTemplateNoteInsertedToBeInteractableWithEditor) {
|
||||
return
|
||||
}
|
||||
|
||||
if (note.lastSyncBegan || note.dirty) {
|
||||
if (note.lastSyncEnd) {
|
||||
if (note.dirty || (note.lastSyncBegan && note.lastSyncBegan.getTime() > note.lastSyncEnd.getTime())) {
|
||||
this.showSavingStatus()
|
||||
} else if (
|
||||
this.context?.getStatusManager().hasMessage(SCREEN_COMPOSE) &&
|
||||
note.lastSyncBegan &&
|
||||
note.lastSyncEnd.getTime() > note.lastSyncBegan.getTime()
|
||||
) {
|
||||
this.showAllChangesSavedStatus()
|
||||
}
|
||||
} else {
|
||||
this.showSavingStatus()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.removeStreamComponents = this.context?.streamItems(ContentType.Component, async ({ source }) => {
|
||||
if (isPayloadSourceInternalChange(source)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.note) {
|
||||
return
|
||||
}
|
||||
|
||||
void this.reloadComponentEditorState()
|
||||
})
|
||||
|
||||
this.removeAppEventObserver = this.context?.addEventObserver(async eventName => {
|
||||
if (eventName === ApplicationEvent.CompletedFullSync) {
|
||||
/** if we're still dirty, don't change status, a sync is likely upcoming. */
|
||||
if (!this.note.dirty && this.state.saveError) {
|
||||
this.showAllChangesSavedStatus()
|
||||
}
|
||||
} else if (eventName === ApplicationEvent.FailedSync) {
|
||||
/**
|
||||
* Only show error status in editor if the note is dirty.
|
||||
* Otherwise, it means the originating sync came from somewhere else
|
||||
* and we don't want to display an error here.
|
||||
*/
|
||||
if (this.note.dirty) {
|
||||
this.showErrorStatus('Sync Unavailable (changes saved offline)')
|
||||
}
|
||||
} else if (eventName === ApplicationEvent.LocalDatabaseWriteError) {
|
||||
this.showErrorStatus('Offline Saving Issue (changes not saved)')
|
||||
}
|
||||
})
|
||||
|
||||
this.removeStateEventObserver = this.context?.getAppState().addStateEventObserver(state => {
|
||||
if (state === AppStateEventType.DrawerOpen) {
|
||||
this.dismissKeyboard()
|
||||
/**
|
||||
* Saves latest note state before any change might happen in the drawer
|
||||
*/
|
||||
}
|
||||
})
|
||||
|
||||
if (this.editor.isTemplateNote && Platform.OS === 'ios') {
|
||||
setTimeout(() => {
|
||||
this.editorViewRef?.current?.focus()
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
override componentWillUnmount() {
|
||||
this.dismissKeyboard()
|
||||
this.removeNoteInnerValueObserver && this.removeNoteInnerValueObserver()
|
||||
this.removeAppEventObserver && this.removeAppEventObserver()
|
||||
this.removeStreamComponents && this.removeStreamComponents()
|
||||
this.removeStateEventObserver && this.removeStateEventObserver()
|
||||
this.removeComponentHandler && this.removeComponentHandler()
|
||||
this.removeStateEventObserver = undefined
|
||||
this.removeNoteInnerValueObserver = undefined
|
||||
this.removeComponentHandler = undefined
|
||||
this.removeStreamComponents = undefined
|
||||
this.removeAppEventObserver = undefined
|
||||
this.context?.getStatusManager()?.setMessage(SCREEN_COMPOSE, '')
|
||||
if (this.state.componentViewer && this.componentManager) {
|
||||
this.componentManager.destroyComponentViewer(this.state.componentViewer)
|
||||
}
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout)
|
||||
}
|
||||
if (this.statusTimeout) {
|
||||
clearTimeout(this.statusTimeout)
|
||||
}
|
||||
if (this.downloadingMessageTimeout) {
|
||||
clearTimeout(this.downloadingMessageTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Because note.locked accesses note.content.appData,
|
||||
* we do not want to expose the template to direct access to note.locked,
|
||||
* otherwise an exception will occur when trying to access note.locked if the note
|
||||
* is deleted. There is potential for race conditions to occur with setState, where a
|
||||
* previous setState call may have queued a digest cycle, and the digest cycle triggers
|
||||
* on a deleted note.
|
||||
*/
|
||||
get noteLocked() {
|
||||
if (!this.note) {
|
||||
return false
|
||||
}
|
||||
return this.note.locked
|
||||
}
|
||||
|
||||
setStatus = (status: string, color?: string, wait = true) => {
|
||||
if (this.statusTimeout) {
|
||||
clearTimeout(this.statusTimeout)
|
||||
}
|
||||
if (wait) {
|
||||
this.statusTimeout = setTimeout(() => {
|
||||
this.context?.getStatusManager()?.setMessage(SCREEN_COMPOSE, status, color)
|
||||
}, MINIMUM_STATUS_DURATION)
|
||||
} else {
|
||||
this.context?.getStatusManager()?.setMessage(SCREEN_COMPOSE, status, color)
|
||||
}
|
||||
}
|
||||
|
||||
showSavingStatus = () => {
|
||||
this.setStatus('Saving...', undefined, false)
|
||||
}
|
||||
|
||||
showAllChangesSavedStatus = () => {
|
||||
this.setState({
|
||||
saveError: false,
|
||||
})
|
||||
const offlineStatus = this.context?.hasAccount() ? '' : ' (offline)'
|
||||
this.setStatus('All changes saved' + offlineStatus)
|
||||
}
|
||||
|
||||
showErrorStatus = (message: string) => {
|
||||
this.setState({
|
||||
saveError: true,
|
||||
})
|
||||
this.setStatus(message)
|
||||
}
|
||||
|
||||
get note() {
|
||||
return this.editor.note
|
||||
}
|
||||
|
||||
dismissKeyboard = () => {
|
||||
Keyboard.dismiss()
|
||||
this.editorViewRef.current?.blur()
|
||||
}
|
||||
|
||||
get componentManager() {
|
||||
return this.context?.mobileComponentManager as ComponentManager
|
||||
}
|
||||
|
||||
async associateComponentWithCurrentNote(component: SNComponent) {
|
||||
const note = this.note
|
||||
if (!note) {
|
||||
return
|
||||
}
|
||||
return this.context?.mutator.changeItem(component, (m: ItemMutator) => {
|
||||
const mutator = m as ComponentMutator
|
||||
mutator.removeDisassociatedItemId(note.uuid)
|
||||
mutator.associateWithItem(note.uuid)
|
||||
})
|
||||
}
|
||||
|
||||
reloadComponentEditorState = async () => {
|
||||
this.setState({
|
||||
downloadingEditor: false,
|
||||
loadingWebview: false,
|
||||
webViewError: undefined,
|
||||
})
|
||||
|
||||
const associatedEditor = this.componentManager.editorForNote(this.note)
|
||||
|
||||
/** Editors cannot interact with template notes so the note must be inserted */
|
||||
if (associatedEditor && this.editor.isTemplateNote) {
|
||||
await this.editor.insertTemplatedNote()
|
||||
void this.associateComponentWithCurrentNote(associatedEditor)
|
||||
}
|
||||
|
||||
if (!associatedEditor) {
|
||||
if (this.state.componentViewer) {
|
||||
this.componentManager.destroyComponentViewer(this.state.componentViewer)
|
||||
this.setState({ componentViewer: undefined })
|
||||
}
|
||||
} else if (associatedEditor.uuid !== this.state.componentViewer?.component.uuid) {
|
||||
if (this.state.componentViewer) {
|
||||
this.componentManager.destroyComponentViewer(this.state.componentViewer)
|
||||
}
|
||||
if (this.componentManager.isComponentThirdParty(associatedEditor.identifier)) {
|
||||
await this.componentManager.preloadThirdPartyIndexPathFromDisk(associatedEditor.identifier)
|
||||
}
|
||||
this.loadComponentViewer(associatedEditor)
|
||||
}
|
||||
}
|
||||
|
||||
loadComponentViewer(component: SNComponent) {
|
||||
this.setState({
|
||||
componentViewer: this.componentManager.createComponentViewer(component, this.note.uuid),
|
||||
})
|
||||
}
|
||||
|
||||
async forceReloadExistingEditor() {
|
||||
if (this.state.componentViewer) {
|
||||
this.componentManager.destroyComponentViewer(this.state.componentViewer)
|
||||
}
|
||||
|
||||
this.setState({
|
||||
componentViewer: undefined,
|
||||
loadingWebview: false,
|
||||
webViewError: undefined,
|
||||
})
|
||||
|
||||
const associatedEditor = this.componentManager.editorForNote(this.note)
|
||||
if (associatedEditor) {
|
||||
this.loadComponentViewer(associatedEditor)
|
||||
}
|
||||
}
|
||||
|
||||
saveNote = async (params: { newTitle?: string; newText?: string }) => {
|
||||
if (this.editor.isTemplateNote) {
|
||||
await this.editor.insertTemplatedNote()
|
||||
}
|
||||
|
||||
if (!this.context?.items.findItem(this.note.uuid)) {
|
||||
void this.context?.alertService.alert('Attempting to save this note has failed. The note cannot be found.')
|
||||
return
|
||||
}
|
||||
|
||||
const { newTitle, newText } = params
|
||||
|
||||
await this.context.mutator.changeItem(
|
||||
this.note,
|
||||
mutator => {
|
||||
const noteMutator = mutator as NoteMutator
|
||||
|
||||
if (newTitle != null) {
|
||||
noteMutator.title = newTitle
|
||||
}
|
||||
|
||||
if (newText != null) {
|
||||
noteMutator.text = newText
|
||||
|
||||
const substring = newText.substring(0, NOTE_PREVIEW_CHAR_LIMIT)
|
||||
const shouldTruncate = newText.length > NOTE_PREVIEW_CHAR_LIMIT
|
||||
const previewPlain = substring + (shouldTruncate ? '...' : '')
|
||||
noteMutator.preview_plain = previewPlain
|
||||
noteMutator.preview_html = undefined
|
||||
}
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout)
|
||||
}
|
||||
const noDebounce = this.context?.noAccount()
|
||||
const syncDebouceMs = noDebounce ? SAVE_TIMEOUT_NO_DEBOUNCE : SAVE_TIMEOUT_DEBOUNCE
|
||||
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
void this.context?.sync.sync()
|
||||
}, syncDebouceMs)
|
||||
}
|
||||
|
||||
onTitleChange = (newTitle: string) => {
|
||||
if (this.note.locked) {
|
||||
void this.context?.alertService?.alert(EditingIsDisabledText)
|
||||
return
|
||||
}
|
||||
|
||||
this.setState(
|
||||
{
|
||||
title: newTitle,
|
||||
},
|
||||
() => this.saveNote({ newTitle: newTitle }),
|
||||
)
|
||||
}
|
||||
|
||||
onContentChange = (text: string) => {
|
||||
if (this.note.locked) {
|
||||
void this.context?.alertService?.alert(EditingIsDisabledText)
|
||||
return
|
||||
}
|
||||
|
||||
void this.saveNote({ newText: text })
|
||||
}
|
||||
|
||||
onLoadWebViewStart = () => {
|
||||
this.setState({
|
||||
loadingWebview: true,
|
||||
webViewError: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
onLoadWebViewEnd = () => {
|
||||
this.setState({
|
||||
loadingWebview: false,
|
||||
})
|
||||
}
|
||||
|
||||
onLoadWebViewError = (error: ComponentLoadingError, desc?: string) => {
|
||||
this.setState({
|
||||
loadingWebview: false,
|
||||
webViewError: error,
|
||||
webViewErrorDesc: desc,
|
||||
})
|
||||
}
|
||||
|
||||
onDownloadEditorStart = () => {
|
||||
this.setState({
|
||||
downloadingEditor: true,
|
||||
})
|
||||
}
|
||||
|
||||
onDownloadEditorEnd = () => {
|
||||
if (this.downloadingMessageTimeout) {
|
||||
clearTimeout(this.downloadingMessageTimeout)
|
||||
}
|
||||
|
||||
this.downloadingMessageTimeout = setTimeout(
|
||||
() =>
|
||||
this.setState({
|
||||
downloadingEditor: false,
|
||||
}),
|
||||
this.state.webViewError ? 0 : 200,
|
||||
)
|
||||
}
|
||||
|
||||
getErrorText(): string {
|
||||
let text = ''
|
||||
switch (this.state.webViewError) {
|
||||
case ComponentLoadingError.ChecksumMismatch:
|
||||
text = 'The remote editor signature differs from the expected value.'
|
||||
break
|
||||
case ComponentLoadingError.DoesntExist:
|
||||
text = 'The local editor files do not exist.'
|
||||
break
|
||||
case ComponentLoadingError.FailedDownload:
|
||||
text = 'The editor failed to download.'
|
||||
break
|
||||
case ComponentLoadingError.LocalServerFailure:
|
||||
text = 'The local component server has an error.'
|
||||
break
|
||||
case ComponentLoadingError.Unknown:
|
||||
text = 'An unknown error occurred.'
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if (this.state.webViewErrorDesc) {
|
||||
text += `Webview Error: ${this.state.webViewErrorDesc}`
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
override render() {
|
||||
const shouldDisplayEditor =
|
||||
this.state.componentViewer && Boolean(this.note) && !this.note.prefersPlainEditor && !this.state.webViewError
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ThemeContext.Consumer>
|
||||
{theme => (
|
||||
<>
|
||||
{this.noteLocked && (
|
||||
<LockedContainer>
|
||||
<Icon name={ThemeService.nameForIcon(ICON_LOCK)} size={16} color={theme.stylekitBackgroundColor} />
|
||||
<LockedText>Note Editing Disabled</LockedText>
|
||||
</LockedContainer>
|
||||
)}
|
||||
{this.state.webViewError && (
|
||||
<LockedContainer>
|
||||
<Icon name={ThemeService.nameForIcon(ICON_ALERT)} size={16} color={theme.stylekitBackgroundColor} />
|
||||
<LockedText>
|
||||
Unable to load {this.state.componentViewer?.component.name} — {this.getErrorText()}
|
||||
</LockedText>
|
||||
<WebViewReloadButton
|
||||
onPress={() => {
|
||||
void this.forceReloadExistingEditor()
|
||||
}}
|
||||
>
|
||||
<WebViewReloadButtonText>Reload</WebViewReloadButtonText>
|
||||
</WebViewReloadButton>
|
||||
</LockedContainer>
|
||||
)}
|
||||
<ThemeServiceContext.Consumer>
|
||||
{themeService => (
|
||||
<>
|
||||
<NoteTitleInput
|
||||
testID="noteTitleField"
|
||||
onChangeText={this.onTitleChange}
|
||||
value={this.state.title}
|
||||
placeholder={'Add Title'}
|
||||
selectionColor={theme.stylekitInfoColor}
|
||||
underlineColorAndroid={'transparent'}
|
||||
placeholderTextColor={theme.stylekitNeutralColor}
|
||||
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
|
||||
autoCorrect={true}
|
||||
autoCapitalize={'sentences'}
|
||||
/>
|
||||
{(this.state.downloadingEditor ||
|
||||
(this.state.loadingWebview && themeService?.isLikelyUsingDarkColorTheme())) && (
|
||||
<LoadingWebViewContainer locked={this.noteLocked}>
|
||||
<LoadingText>
|
||||
{'Loading '}
|
||||
{this.state.componentViewer?.component.name}...
|
||||
</LoadingText>
|
||||
</LoadingWebViewContainer>
|
||||
)}
|
||||
{/* setting webViewError to false on onLoadEnd will cause an infinite loop on Android upon webview error, so, don't do that. */}
|
||||
{shouldDisplayEditor && this.state.componentViewer && (
|
||||
<ComponentView
|
||||
key={this.state.componentViewer?.identifier}
|
||||
componentViewer={this.state.componentViewer}
|
||||
onLoadStart={this.onLoadWebViewStart}
|
||||
onLoadEnd={this.onLoadWebViewEnd}
|
||||
onLoadError={this.onLoadWebViewError}
|
||||
onDownloadEditorStart={this.onDownloadEditorStart}
|
||||
onDownloadEditorEnd={this.onDownloadEditorEnd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!shouldDisplayEditor && !isNullOrUndefined(this.note) && Platform.OS === 'android' && (
|
||||
<TextContainer>
|
||||
<StyledTextView
|
||||
testID="noteContentField"
|
||||
ref={this.editorViewRef}
|
||||
autoFocus={false}
|
||||
value={this.state.text}
|
||||
selectionColor={lighten(theme.stylekitInfoColor, 0.35)}
|
||||
handlesColor={theme.stylekitInfoColor}
|
||||
onChangeText={this.onContentChange}
|
||||
errorState={false}
|
||||
/>
|
||||
</TextContainer>
|
||||
)}
|
||||
{/* Empty wrapping view fixes native textview crashing */}
|
||||
{!shouldDisplayEditor && Platform.OS === 'ios' && (
|
||||
<View key={this.note.uuid}>
|
||||
<StyledTextView
|
||||
testID="noteContentField"
|
||||
ref={this.editorViewRef}
|
||||
autoFocus={false}
|
||||
multiline
|
||||
value={this.state.text}
|
||||
keyboardDismissMode={'interactive'}
|
||||
keyboardAppearance={themeService?.keyboardColorForActiveTheme()}
|
||||
selectionColor={lighten(theme.stylekitInfoColor)}
|
||||
onChangeText={this.onContentChange}
|
||||
editable={!this.noteLocked}
|
||||
errorState={!!this.state.webViewError}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ThemeServiceContext.Consumer>
|
||||
</>
|
||||
)}
|
||||
</ThemeContext.Consumer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user