Files
standardnotes-app-web/packages/mobile/src/Screens/SideMenu/NoteSideMenu.tsx
2022-10-12 21:57:51 +05:30

676 lines
20 KiB
TypeScript

import { associateComponentWithNote } from '@Lib/ComponentManager'
import { useChangeNote, useDeleteNoteWithPrivileges, useProtectOrUnprotectNote } from '@Lib/SnjsHelperHooks'
import { useFocusEffect, useNavigation } from '@react-navigation/native'
import { AppStackNavigationProp } from '@Root/AppStack'
import { useSafeApplicationContext } from '@Root/Hooks/useSafeApplicationContext'
import { SCREEN_COMPOSE, SCREEN_INPUT_MODAL_TAG, SCREEN_NOTE_HISTORY } from '@Root/Screens/screens'
import { Files } from '@Root/Screens/SideMenu/Files'
import { Listed } from '@Root/Screens/SideMenu/Listed'
import {
ApplicationEvent,
ButtonType,
ComponentArea,
ComponentMutator,
ContentType,
FeatureIdentifier,
FeatureStatus,
FindNativeFeature,
NoteMutator,
NoteViewController,
PayloadEmitSource,
PrefKey,
SmartView,
SNComponent,
SNNote,
SNTag,
} from '@standardnotes/snjs'
import { useCustomActionSheet } from '@Style/CustomActionSheet'
import {
ICON_ARCHIVE,
ICON_BOOKMARK,
ICON_FINGER_PRINT,
ICON_HISTORY,
ICON_LOCK,
ICON_MEDICAL,
ICON_PRICE_TAG,
ICON_SHARE,
ICON_TRASH,
} from '@Style/Icons'
import { ThemeService } from '@Style/ThemeService'
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { Platform, Share } from 'react-native'
import FAB from 'react-native-fab'
import { FlatList } from 'react-native-gesture-handler'
import DrawerLayout from 'react-native-gesture-handler/DrawerLayout'
import Icon from 'react-native-vector-icons/Ionicons'
import { ThemeContext } from 'styled-components'
import { SafeAreaContainer, useStyles } from './NoteSideMenu.styled'
import { SideMenuOption, SideMenuOptionIconDescriptionType, SideMenuSection } from './SideMenuSection'
import { TagSelectionList } from './TagSelectionList'
function sortAlphabetically(array: SNComponent[]): SNComponent[] {
return array.sort((a, b) => {
const aName = FindNativeFeature(a.identifier)?.name || a.name
const bName = FindNativeFeature(b.identifier)?.name || b.name
return aName.toLowerCase() < bName.toLowerCase() ? -1 : 1
})
}
type Props = {
drawerRef: DrawerLayout | null
drawerOpen: boolean
}
function useEditorComponents(): SNComponent[] {
const application = useSafeApplicationContext()
const [components, setComponents] = useState<SNComponent[]>([])
useEffect(() => {
const removeComponentsObserver = application.streamItems(ContentType.Component, () => {
const displayComponents = sortAlphabetically(application.componentManager.componentsForArea(ComponentArea.Editor))
setComponents(displayComponents)
})
return () => {
if (application) {
removeComponentsObserver()
}
}
}, [application])
return components
}
export const NoteSideMenu = React.memo((props: Props) => {
// Context
const theme = useContext(ThemeContext)
const application = useSafeApplicationContext()
const navigation = useNavigation<AppStackNavigationProp<typeof SCREEN_COMPOSE>['navigation']>()
const { showActionSheet } = useCustomActionSheet()
const styles = useStyles(theme)
// State
const [editor, setEditor] = useState<NoteViewController | undefined>(undefined)
const [note, setNote] = useState<SNNote | undefined>(undefined)
const [selectedTags, setSelectedTags] = useState<SNTag[]>([])
const [attachedFilesLength, setAttachedFilesLength] = useState(0)
const [shouldAddTagHierarchy, setShouldAddTagHierachy] = useState(() =>
application.getPreference(PrefKey.NoteAddToParentFolders, true),
)
useEffect(() => {
const removeEventObserver = application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
setShouldAddTagHierachy(application.getPreference(PrefKey.NoteAddToParentFolders, true))
})
return () => {
removeEventObserver()
}
}, [application])
const components = useEditorComponents()
const [changeNote] = useChangeNote(note, editor)
const [protectOrUnprotectNote] = useProtectOrUnprotectNote(note, editor)
const [deleteNote] = useDeleteNoteWithPrivileges(
note!,
async () => {
await application.mutator.deleteItem(note!)
props.drawerRef?.closeDrawer()
if (!application.getAppState().isInTabletMode) {
navigation.popToTop()
}
},
() => {
void changeNote((mutator) => {
mutator.trashed = true
}, false)
props.drawerRef?.closeDrawer()
if (!application.getAppState().isInTabletMode) {
navigation.popToTop()
}
},
editor,
)
useEffect(() => {
if (!note) {
setAttachedFilesLength(0)
return
}
setAttachedFilesLength(application.items.getSortedFilesLinkingToItem(note).length)
}, [application, note])
useEffect(() => {
if (!note) {
return
}
const removeFilesObserver = application.streamItems(ContentType.File, () => {
setAttachedFilesLength(application.items.getSortedFilesLinkingToItem(note).length)
})
return () => {
removeFilesObserver()
}
}, [application, note])
useEffect(() => {
let mounted = true
if ((!editor || props.drawerOpen) && mounted) {
const initialEditor = application.editorGroup.activeItemViewController as NoteViewController
const tempNote = initialEditor?.item
setEditor(initialEditor)
setNote(tempNote)
}
return () => {
mounted = false
}
}, [application, editor, props.drawerOpen])
useEffect(() => {
let mounted = true
const removeEditorObserver = application.editorGroup.addActiveControllerChangeObserver(() => {
if (mounted) {
const activeController = application.editorGroup.activeItemViewController as NoteViewController
setNote(activeController?.item)
setEditor(activeController)
}
})
return () => {
mounted = false
removeEditorObserver && removeEditorObserver()
}
}, [application])
const reloadTags = useCallback(() => {
if (note) {
const tags = application.getAppState().getNoteTags(note)
setSelectedTags(tags)
}
}, [application, note])
useEffect(() => {
let mounted = true
const removeObserver = editor?.addNoteInnerValueChangeObserver((newNote, source) => {
if (mounted && props.drawerOpen) {
if (source !== PayloadEmitSource.ComponentRetrieved) {
setNote(newNote)
}
}
})
return () => {
if (removeObserver) {
removeObserver()
}
mounted = false
}
}, [editor, note?.uuid, props.drawerOpen, reloadTags])
useEffect(() => {
let isMounted = true
const removeTagsObserver = application.streamItems(ContentType.Tag, () => {
if (!note) {
return
}
if (isMounted && props.drawerOpen) {
reloadTags()
}
return () => {
isMounted = false
removeTagsObserver && removeTagsObserver()
}
})
}, [application, note, props.drawerOpen, reloadTags])
const disassociateComponentWithCurrentNote = useCallback(
async (component: SNComponent) => {
if (note) {
return application.mutator.changeItem(component, (m) => {
const mutator = m as ComponentMutator
mutator.removeAssociatedItemId(note.uuid)
mutator.disassociateWithItem(note.uuid)
})
}
return
},
[application, note],
)
const onEditorPress = useCallback(
async (selectedComponent?: SNComponent) => {
if (!note) {
return
}
if (note?.locked) {
void application.alertService.alert(
"This note has editing disabled. If you'd like to edit its options, enable editing on it, and try again.",
)
return
}
if (editor?.isTemplateNote) {
await editor?.insertTemplatedNote()
}
const activeEditorComponent = application.componentManager.editorForNote(note)
props.drawerRef?.closeDrawer()
if (!selectedComponent) {
if (!note?.prefersPlainEditor) {
await application.mutator.changeItem(
note,
(mutator) => {
const noteMutator = mutator as NoteMutator
noteMutator.prefersPlainEditor = true
},
false,
)
}
if (activeEditorComponent?.isExplicitlyEnabledForItem(note.uuid) || activeEditorComponent?.isMobileDefault) {
await disassociateComponentWithCurrentNote(activeEditorComponent)
}
} else if (selectedComponent.area === ComponentArea.Editor) {
const currentEditor = activeEditorComponent
if (currentEditor && selectedComponent !== currentEditor) {
await disassociateComponentWithCurrentNote(currentEditor)
}
const prefersPlain = note.prefersPlainEditor
if (prefersPlain) {
await application.mutator.changeItem(
note,
(mutator) => {
const noteMutator = mutator as NoteMutator
noteMutator.prefersPlainEditor = false
},
false,
)
}
await associateComponentWithNote(application, selectedComponent, note)
}
/** Dirtying can happen above */
void application.sync.sync()
},
[application, disassociateComponentWithCurrentNote, editor, note, props.drawerRef],
)
const onEdtiorLongPress = useCallback(
async (component?: SNComponent) => {
const currentDefault = application.componentManager
.componentsForArea(ComponentArea.Editor)
.filter((e) => e.isMobileDefault)[0]
let isDefault = false
if (!component) {
// System editor
if (currentDefault) {
isDefault = false
}
} else {
isDefault = component.isMobileDefault
}
let action = isDefault ? 'Remove as Mobile Default' : 'Set as Mobile Default'
if (!component && !currentDefault) {
// Long pressing on plain editor while it is default, no actions available
action = 'Is Mobile Default'
}
const setAsDefault = () => {
if (currentDefault) {
void application.mutator.changeItem(currentDefault, (m) => {
const mutator = m as ComponentMutator
mutator.isMobileDefault = false
})
}
if (component) {
void application.mutator.changeAndSaveItem(component, (m) => {
const mutator = m as ComponentMutator
mutator.isMobileDefault = true
})
}
}
const removeAsDefault = () => {
void application.mutator.changeItem(currentDefault, (m) => {
const mutator = m as ComponentMutator
mutator.isMobileDefault = false
})
}
showActionSheet({
title: component?.name ?? 'Plain text',
options: [
{
text: action,
callback: () => {
if (!component) {
setAsDefault()
} else {
if (isDefault) {
removeAsDefault()
} else {
setAsDefault()
}
}
},
},
],
})
},
[application, showActionSheet],
)
const editors = useMemo(() => {
if (!note) {
return []
}
const componentEditor = application.componentManager.editorForNote(note)
const options: SideMenuOption[] = [
{
text: 'Plain text',
key: 'plain-editor',
selected: !componentEditor,
onSelect: () => {
void onEditorPress(undefined)
},
onLongPress: () => {
void onEdtiorLongPress(undefined)
},
},
]
components.map((component) => {
options.push({
text: FindNativeFeature(component.identifier)?.name || component.name,
subtext: component.isMobileDefault ? 'Mobile Default' : undefined,
key: component.uuid || component.name,
selected: component.uuid === componentEditor?.uuid,
onSelect: () => {
void onEditorPress(component)
},
onLongPress: () => {
void onEdtiorLongPress(component)
},
})
})
if (options.length === 1) {
options.push({
text: 'Unlock More Types',
key: 'get-editors',
iconDesc: {
type: SideMenuOptionIconDescriptionType.Icon,
name: ThemeService.nameForIcon(ICON_MEDICAL),
side: 'right',
size: 17,
},
onSelect: () => {
application.deviceInterface?.openUrl('https://standardnotes.com/plans')
},
})
}
return options
}, [note, application, components, onEditorPress, onEdtiorLongPress])
useFocusEffect(
useCallback(() => {
let mounted = true
if (mounted) {
reloadTags()
}
return () => {
mounted = false
}
}, [reloadTags]),
)
const leaveEditor = useCallback(() => {
props.drawerRef?.closeDrawer()
navigation.goBack()
}, [props.drawerRef, navigation])
const isEntitledToFiles = application.features.getFeatureStatus(FeatureIdentifier.Files) === FeatureStatus.Entitled
const noteOptions = useMemo(() => {
if (!note) {
return
}
const pinOption = note.pinned ? 'Unpin' : 'Pin'
const pinEvent = () =>
changeNote((mutator) => {
mutator.pinned = !note.pinned
}, false)
const archiveOption = note.archived ? 'Unarchive' : 'Archive'
const archiveEvent = () => {
if (note.locked) {
void application.alertService.alert(
`This note has editing disabled. If you'd like to ${archiveOption.toLowerCase()} it, enable editing on it, and try again.`,
)
return
}
void changeNote((mutator) => {
mutator.archived = !note.archived
}, false)
leaveEditor()
}
const lockOption = note.locked ? 'Enable editing' : 'Prevent editing'
const lockEvent = () =>
changeNote((mutator) => {
mutator.locked = !note.locked
}, false)
const protectOption = note.protected ? 'Remove password protection' : 'Password protect'
const protectEvent = async () => await protectOrUnprotectNote()
const openSessionHistory = () => {
if (!editor?.isTemplateNote) {
props.drawerRef?.closeDrawer()
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
navigation.navigate('HistoryStack', {
screen: SCREEN_NOTE_HISTORY,
params: { noteUuid: note.uuid },
})
}
}
const shareNote = () => {
if (note) {
void application.getAppState().performActionWithoutStateChangeImpact(() => {
void Share.share({
title: note.title,
message: note.text,
})
})
}
}
const rawOptions = [
{ text: pinOption, onSelect: pinEvent, icon: ICON_BOOKMARK },
{ text: archiveOption, onSelect: archiveEvent, icon: ICON_ARCHIVE },
{ text: lockOption, onSelect: lockEvent, icon: ICON_LOCK },
{ text: protectOption, onSelect: protectEvent, icon: ICON_FINGER_PRINT },
{
text: 'History',
onSelect: openSessionHistory,
icon: ICON_HISTORY,
},
{ text: 'Share', onSelect: shareNote, icon: ICON_SHARE },
]
if (!note.trashed) {
rawOptions.push({
text: 'Move to Trash',
onSelect: async () => deleteNote(false),
icon: ICON_TRASH,
})
}
let options: SideMenuOption[] = rawOptions.map((rawOption) => ({
text: rawOption.text,
key: rawOption.icon,
iconDesc: {
type: SideMenuOptionIconDescriptionType.Icon,
side: 'right' as const,
name: ThemeService.nameForIcon(rawOption.icon),
},
onSelect: rawOption.onSelect,
}))
if (note.trashed) {
options = options.concat([
{
text: 'Restore',
key: 'restore-note',
onSelect: () => {
void changeNote((mutator) => {
mutator.trashed = false
}, false)
},
},
{
text: 'Delete permanently',
textClass: 'danger' as const,
key: 'delete-forever',
onSelect: async () => deleteNote(true),
},
{
text: 'Empty Trash',
textClass: 'danger' as const,
key: 'empty trash',
onSelect: async () => {
const count = application.items.trashedItems.length
const confirmed = await application.alertService?.confirm(
`Are you sure you want to permanently delete ${count} notes?`,
'Empty Trash',
'Delete',
ButtonType.Danger,
)
if (confirmed) {
await application.mutator.emptyTrash()
props.drawerRef?.closeDrawer()
if (!application.getAppState().isInTabletMode) {
navigation.popToTop()
}
void application.sync.sync()
}
},
},
])
}
return options
}, [
application,
changeNote,
deleteNote,
editor?.isTemplateNote,
leaveEditor,
navigation,
note,
props.drawerRef,
protectOrUnprotectNote,
])
const onTagSelect = useCallback(
async (tag: SNTag | SmartView, addTagHierachy: boolean) => {
const isSelected = selectedTags.findIndex((selectedTag) => selectedTag.uuid === tag.uuid) > -1
if (note) {
if (isSelected) {
await application.mutator.changeItem(tag, (mutator) => {
mutator.removeItemAsRelationship(note)
})
} else {
await application.items.addTagToNote(note, tag as SNTag, addTagHierachy)
}
}
reloadTags()
void application.sync.sync()
},
[application, note, reloadTags, selectedTags],
)
if (!editor || !note) {
return null
}
enum MenuSections {
FilesSection = 'files-section',
OptionsSection = 'options-section',
EditorsSection = 'editors-section',
ListedSection = 'listed-section',
TagsSection = 'tags-section',
}
return (
<SafeAreaContainer edges={['top', 'bottom', 'right']}>
<FlatList
style={styles.sections}
data={Object.values(MenuSections).map((key) => ({
key,
noteOptions,
editorComponents: editors,
onTagSelect,
selectedTags,
}))}
renderItem={({ item }) => {
const { OptionsSection, EditorsSection, ListedSection, TagsSection, FilesSection } = MenuSections
if (item.key === FilesSection) {
let collapsedLabel = 'Tap to expand'
if (isEntitledToFiles) {
collapsedLabel = `${attachedFilesLength ? `${attachedFilesLength}` : 'No'} attached file${
attachedFilesLength === 1 ? '' : 's'
}`
}
return (
<SideMenuSection title={'Files'} customCollapsedLabel={collapsedLabel} collapsed={false}>
<Files note={note} />
</SideMenuSection>
)
}
if (item.key === OptionsSection) {
return <SideMenuSection title="Options" options={item.noteOptions} />
}
if (item.key === EditorsSection) {
return <SideMenuSection title="Note Type" options={item.editorComponents} collapsed={true} />
}
if (item.key === ListedSection) {
return (
<SideMenuSection title="Listed" collapsed={true}>
<Listed note={note} />
</SideMenuSection>
)
}
if (item.key === TagsSection) {
return (
<SideMenuSection title="Tags">
<TagSelectionList
hasBottomPadding={Platform.OS === 'android'}
contentType={ContentType.Tag}
onTagSelect={(tag) => item.onTagSelect(tag, shouldAddTagHierarchy)}
selectedTags={item.selectedTags}
emptyPlaceholder={'Create a new tag using the tag button in the bottom right corner.'}
/>
</SideMenuSection>
)
}
return null
}}
/>
<FAB
buttonColor={theme.stylekitInfoColor}
iconTextColor={theme.stylekitInfoContrastColor}
onClickAction={() => navigation.navigate(SCREEN_INPUT_MODAL_TAG, { noteUuid: note.uuid })}
visible={true}
size={30}
iconTextComponent={<Icon name={ThemeService.nameForIcon(ICON_PRICE_TAG)} />}
/>
</SafeAreaContainer>
)
})