feat: mobile app package (#1075)

This commit is contained in:
Mo
2022-06-09 09:45:15 -05:00
committed by GitHub
parent 58b63898de
commit 8248a38280
336 changed files with 47696 additions and 22563 deletions

View 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),
}))``

View 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>
)
}

View 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
},
)

View 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>
)
}
}