diff --git a/ios/Podfile.lock b/ios/Podfile.lock index a7581249..78174102 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -243,6 +243,8 @@ PODS: - React - react-native-safe-area-context (3.1.6): - React + - react-native-segmented-control (2.1.1): + - React - react-native-sodium (0.4.0): - React - react-native-version-info (1.0.1): @@ -370,6 +372,7 @@ DEPENDENCIES: - react-native-fingerprint-scanner (from `../node_modules/react-native-fingerprint-scanner`) - react-native-mail (from `../node_modules/react-native-mail`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - "react-native-segmented-control (from `../node_modules/@react-native-community/segmented-control`)" - react-native-sodium (from `../node_modules/react-native-sodium`) - react-native-version-info (from `../node_modules/react-native-version-info`) - react-native-webview (from `../node_modules/react-native-webview`) @@ -457,6 +460,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-mail" react-native-safe-area-context: :path: "../node_modules/react-native-safe-area-context" + react-native-segmented-control: + :path: "../node_modules/@react-native-community/segmented-control" react-native-sodium: :path: "../node_modules/react-native-sodium" react-native-version-info: @@ -547,6 +552,7 @@ SPEC CHECKSUMS: react-native-fingerprint-scanner: f0d8190ceaf0b9e1893e3379d78724375b8f6ea7 react-native-mail: a864fb211feaa5845c6c478a3266de725afdce89 react-native-safe-area-context: 4fb3cdeb4a405ec19f18aca80ef2144381ffc761 + react-native-segmented-control: 05d93c1c7576b53189720012a3e8a3c23608d849 react-native-sodium: ef43e28fdf8d866e68ed06890c32f8d86a570cc7 react-native-version-info: f95938832bdb2ce94c3d045c2e44fabcc88f796a react-native-webview: 0e49a2e85f42b20ea5579928a8b254a07fa901dc diff --git a/package.json b/package.json index 30a5f73c..cc174e98 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@expo/react-native-action-sheet": "^3.8.0", "@react-native-community/async-storage": "1.12.0", "@react-native-community/masked-view": "^0.1.10", + "@react-native-community/segmented-control": "^2.1.1", "@react-navigation/native": "^5.7.3", "@react-navigation/stack": "^5.9.0", "base64-arraybuffer": "^0.2.0", @@ -47,6 +48,7 @@ "react-native-search-box": "standardnotes/react-native-search-box#f61a2b5", "react-native-sodium": "standardnotes/react-native-sodium#f68a35b", "react-native-store-review": "^0.1.5", + "react-native-tab-view": "^2.15.1", "react-native-vector-icons": "^7.0.0", "react-native-version-info": "^1.0.1", "react-native-webview": "^10.8.2", diff --git a/src/App.tsx b/src/App.tsx index 93271449..e3a82676 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ import { AuthenticatePrivileges } from '@Screens/Authenticate/AuthenticatePrivil import { Compose } from '@Screens/Compose/Compose'; import { PasscodeInputModal } from '@Screens/InputModal/PasscodeInputModal'; import { TagInputModal } from '@Screens/InputModal/TagInputModal'; +import { NoteHistory } from '@Screens/NoteHistory/NoteHistory'; import { Root } from '@Screens/Root'; import { ManagePrivileges } from '@Screens/Settings/ManagePrivileges'; import { Settings } from '@Screens/Settings/Settings'; @@ -33,7 +34,7 @@ import { NoteSideMenu } from '@Screens/SideMenu/NoteSideMenu'; import { ICON_CHECKMARK, ICON_CLOSE, ICON_MENU } from '@Style/icons'; import { StyleKit, StyleKitContext } from '@Style/StyleKit'; import { StyleKitTheme } from '@Style/Themes/styled-components'; -import { getDefaultDrawerWidth } from '@Style/Util/getDefaultDraerWidth'; +import { getDefaultDrawerWidth } from '@Style/Util/getDefaultDrawerWidth'; import React, { useCallback, useContext, @@ -63,6 +64,7 @@ import { SCREEN_INPUT_MODAL_TAG, SCREEN_MANAGE_PRIVILEGES, SCREEN_NOTES, + SCREEN_NOTE_HISTORY, SCREEN_SETTINGS, } from './screens/screens'; @@ -75,6 +77,9 @@ type HeaderTitleParams = { type AppStackNavigatorParamList = { [SCREEN_NOTES]: HeaderTitleParams; [SCREEN_COMPOSE]: HeaderTitleParams | undefined; + [SCREEN_NOTE_HISTORY]: + | (HeaderTitleParams & { noteUuid: string }) + | (undefined & { noteUuid: string }); }; type ModalStackNavigatorParamList = { @@ -294,6 +299,22 @@ const AppStackComponent = (props: ModalStackNavigationProp<'AppStack'>) => { })} component={Compose} /> + ({ + title: 'Note history', + headerTitle: ({ children }) => { + return ( + + ); + }, + })} + component={NoteHistory} + /> diff --git a/src/screens/NoteHistory/NoteHistory.styled.ts b/src/screens/NoteHistory/NoteHistory.styled.ts new file mode 100644 index 00000000..1ec12749 --- /dev/null +++ b/src/screens/NoteHistory/NoteHistory.styled.ts @@ -0,0 +1,17 @@ +import styled from 'styled-components/native'; + +export const Container = styled.View``; + +export const DateText = styled.Text` + font-size: 15px; + margin-top: 4px; + opacity: 0.8; + line-height: 21px; +`; + +export const IosTabBarContainer = styled.View` + padding-top: 10px; + padding-bottom: 5px; + padding-left: 12px; + padding-right: 12px; +`; diff --git a/src/screens/NoteHistory/NoteHistory.tsx b/src/screens/NoteHistory/NoteHistory.tsx new file mode 100644 index 00000000..358dfd47 --- /dev/null +++ b/src/screens/NoteHistory/NoteHistory.tsx @@ -0,0 +1,145 @@ +import SegmentedControl from '@react-native-community/segmented-control'; +import { AppStackNavigationProp } from '@Root/App'; +import { ApplicationContext } from '@Root/ApplicationContext'; +import { SCREEN_NOTE_HISTORY } from '@Screens/screens'; +import { StyleKitContext } from '@Style/StyleKit'; +import React, { useCallback, useContext, useState } from 'react'; +import { Dimensions, Platform } from 'react-native'; +import { + NavigationState, + Route, + SceneRendererProps, + TabBar, + TabView, +} from 'react-native-tab-view'; +import { ButtonType, ContentType, PayloadSource, SNNote } from 'snjs'; +import { PayloadContent } from 'snjs/dist/@types/protocol/payloads/generator'; +import { ThemeContext } from 'styled-components/native'; +import { IosTabBarContainer } from './NoteHistory.styled'; +import { RemoteHistory } from './RemoteHistory'; +import { SessionHistory } from './SessionHistory'; + +const initialLayout = { width: Dimensions.get('window').width }; + +type Props = AppStackNavigationProp; +export const NoteHistory = (props: Props) => { + // Context + const application = useContext(ApplicationContext); + const theme = useContext(ThemeContext); + const styleKit = useContext(StyleKitContext); + + // State + const [note] = useState( + () => application?.findItem(props.route.params.noteUuid) as SNNote + ); + const [routes] = React.useState([ + { key: 'session', title: 'Session' }, + { key: 'remote', title: 'Remote' }, + ]); + const [index, setIndex] = useState(0); + + const restore = useCallback( + async (asCopy: boolean, revisionUuid: string, content: PayloadContent) => { + const run = async () => { + if (asCopy) { + const contentCopy = Object.assign({}, content); + if (contentCopy.title) { + contentCopy.title += ' (copy)'; + } + await application?.createManagedItem( + ContentType.Note, + contentCopy, + true + ); + props.navigation.popToTop(); + } else { + await application?.changeAndSaveItem( + revisionUuid, + mutator => { + mutator.setContent(content); + }, + true, + PayloadSource.RemoteActionRetrieved + ); + props.navigation.goBack(); + } + }; + + if (!asCopy) { + const confirmed = await application?.alertService?.confirm( + "Are you sure you want to replace the current note's contents with what you see in this preview?", + 'Restore note', + 'Restore', + ButtonType.Info + ); + if (confirmed) { + run(); + } + } else { + run(); + } + }, + [application, props.navigation] + ); + + const renderScene = ({ + route, + }: { + route: { key: string; title: string }; + }) => { + switch (route.key) { + case 'session': + return ; + case 'remote': + return ; + default: + return null; + } + }; + + const renderTabBar = ( + tabBarProps: SceneRendererProps & { + navigationState: NavigationState; + } + ) => { + return Platform.OS === 'ios' && + parseInt(Platform.Version as string, 10) >= 13 ? ( + + route.title)} + selectedIndex={tabBarProps.navigationState.index} + onChange={event => { + setIndex(event.nativeEvent.selectedSegmentIndex); + }} + /> + + ) : ( + + ); + }; + + return ( + + ); +}; diff --git a/src/screens/NoteHistory/NoteHistoryCell.tsx b/src/screens/NoteHistory/NoteHistoryCell.tsx new file mode 100644 index 00000000..ea3b8cc1 --- /dev/null +++ b/src/screens/NoteHistory/NoteHistoryCell.tsx @@ -0,0 +1,64 @@ +import { + Props as TableCellProps, + SectionedTableCellTouchableHighlight, +} from '@Components/SectionedTableCell'; +import React from 'react'; +import styled, { css } from 'styled-components/native'; + +type Props = { + testID?: string; + disabled?: boolean; + onPress: () => void; + first?: boolean; + last?: boolean; + title: string; + subTitle?: string; +}; + +const Container = styled(SectionedTableCellTouchableHighlight).attrs(props => ({ + underlayColor: props.theme.stylekitBorderColor, +}))` + padding-top: ${12}px; + justify-content: center; +`; +const ButtonContainer = styled.View``; + +type ButtonLabelProps = Pick; +const ButtonLabel = styled.Text` + color: ${props => { + let color = props.theme.stylekitForegroundColor; + if (props.disabled) { + color = 'gray'; + } + return color; + }}; + font-weight: bold; + font-size: ${props => props.theme.mainTextFontSize}px; + ${({ disabled }) => + disabled && + css` + opacity: 0.6; + `} +`; +export const SubTitleText = styled.Text` + font-size: 14px; + margin-top: 4px; + color: ${({ theme }) => theme.stylekitForegroundColor}; + opacity: 0.8; + line-height: 21px; +`; + +export const NoteHistoryCell: React.FC = props => ( + + + {props.title} + {props.subTitle && {props.subTitle}} + + +); diff --git a/src/screens/NoteHistory/RemoteHistory.tsx b/src/screens/NoteHistory/RemoteHistory.tsx new file mode 100644 index 00000000..4a7ace78 --- /dev/null +++ b/src/screens/NoteHistory/RemoteHistory.tsx @@ -0,0 +1,136 @@ +import { ApplicationContext } from '@Root/ApplicationContext'; +import { LoadingContainer, LoadingText } from '@Screens/Notes/NoteList.styled'; +import { useCustomActionSheet } from '@Style/useCustomActionSheet'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { FlatList, ListRenderItem } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { SNNote } from 'snjs'; +import { PayloadContent } from 'snjs/dist/@types/protocol/payloads/generator'; +import { + RemoteHistoryList, + RemoteHistoryListEntry, +} from 'snjs/dist/@types/services/history/history_manager'; +import { NoteHistoryCell } from './NoteHistoryCell'; + +type Props = { + note: SNNote; + restoreNote: ( + asCopy: boolean, + uuid: string, + content: PayloadContent + ) => Promise; +}; +export const RemoteHistory: React.FC = ({ note, restoreNote }) => { + // Context + const application = useContext(ApplicationContext); + const { showActionSheet } = useCustomActionSheet(); + const insets = useSafeAreaInsets(); + + // State + const [remoteHistoryList, setRemoteHistoryList] = useState< + RemoteHistoryList + >(); + const [fetchingRemoteHistory, setFetchingRemoteHistory] = useState(false); + + useEffect(() => { + let isMounted = true; + + const fetchRemoteHistoryList = async () => { + if (note) { + setFetchingRemoteHistory(true); + const newRemoteHistory = await application?.historyManager?.remoteHistoryForItem( + note + ); + if (isMounted) { + setFetchingRemoteHistory(false); + setRemoteHistoryList(newRemoteHistory); + } + } + }; + fetchRemoteHistoryList(); + + return () => { + isMounted = false; + }; + }, [application?.historyManager, note]); + + const onPress = useCallback( + async (item: RemoteHistoryListEntry) => { + const fetchAndRestoreRevision = async (asCopy: boolean) => { + const remoteRevision = await application?.historyManager!.fetchRemoteRevision( + note.uuid, + item + ); + if (remoteRevision) { + restoreNote( + asCopy, + remoteRevision.payload.uuid, + remoteRevision.payload.safeContent + ); + } else { + application?.alertService!.alert( + 'The remote revision could not be loaded. Please try again later.', + 'Error' + ); + return; + } + }; + + showActionSheet(item.updated_at.toLocaleString(), [ + { + text: 'Restore', + callback: () => fetchAndRestoreRevision(false), + }, + { + text: 'Restore as copy', + callback: async () => fetchAndRestoreRevision(true), + }, + ]); + }, + [ + application?.alertService, + application?.historyManager, + note.uuid, + restoreNote, + showActionSheet, + ] + ); + + const RenderItem: + | ListRenderItem + | null + | undefined = ({ item }) => { + return ( + onPress(item)} + title={new Date(item.updated_at).toLocaleString()} + /> + ); + }; + + if ( + fetchingRemoteHistory || + (remoteHistoryList && remoteHistoryList.length === 0) + ) { + const placeholderText = fetchingRemoteHistory + ? 'Loading entries...' + : 'No entries.'; + return ( + + {placeholderText} + + ); + } + + return ( + + keyExtractor={item => item.uuid} + contentContainerStyle={{ paddingBottom: insets.bottom }} + initialNumToRender={10} + windowSize={10} + keyboardShouldPersistTaps={'never'} + data={remoteHistoryList} + renderItem={RenderItem} + /> + ); +}; diff --git a/src/screens/NoteHistory/SessionHistory.tsx b/src/screens/NoteHistory/SessionHistory.tsx new file mode 100644 index 00000000..e6efd94d --- /dev/null +++ b/src/screens/NoteHistory/SessionHistory.tsx @@ -0,0 +1,77 @@ +import { ApplicationContext } from '@Root/ApplicationContext'; +import { useCustomActionSheet } from '@Style/useCustomActionSheet'; +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { FlatList, ListRenderItem } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { ItemSessionHistory, SNNote } from 'snjs'; +import { PayloadContent } from 'snjs/dist/@types/protocol/payloads/generator'; +import { NoteHistoryEntry } from 'snjs/dist/@types/services/history/entries/note_history_entry'; +import { NoteHistoryCell } from './NoteHistoryCell'; + +type Props = { + note: SNNote; + restoreNote: ( + asCopy: boolean, + uuid: string, + content: PayloadContent + ) => Promise; +}; +export const SessionHistory: React.FC = ({ note, restoreNote }) => { + // Context + const application = useContext(ApplicationContext); + const { showActionSheet } = useCustomActionSheet(); + const insets = useSafeAreaInsets(); + + // State + const [sessionHistory, setSessionHistory] = useState(); + + useEffect(() => { + if (note) { + setSessionHistory( + application?.historyManager?.sessionHistoryForItem(note) + ); + } + }, [application?.historyManager, note]); + + const onPress = useCallback( + (item: NoteHistoryEntry) => { + showActionSheet(item.previewTitle(), [ + { + text: 'Restore', + callback: () => + restoreNote(false, item.payload.uuid, item.payload.safeContent), + }, + { + text: 'Restore as copy', + callback: async () => + restoreNote(true, item.payload.uuid, item.payload.safeContent), + }, + ]); + }, + [restoreNote, showActionSheet] + ); + + const RenderItem: ListRenderItem | null | undefined = ({ + item, + }) => { + return ( + onPress(item)} + title={item.previewTitle()} + subTitle={item.previewSubTitle()} + /> + ); + }; + + return ( + + keyExtractor={item => item.previewTitle()} + contentContainerStyle={{ paddingBottom: insets.bottom }} + initialNumToRender={10} + windowSize={10} + keyboardShouldPersistTaps={'never'} + data={sessionHistory?.entries as NoteHistoryEntry[]} + renderItem={RenderItem} + /> + ); +}; diff --git a/src/screens/Notes/NoteCell.tsx b/src/screens/Notes/NoteCell.tsx index 41bef913..d0303471 100644 --- a/src/screens/Notes/NoteCell.tsx +++ b/src/screens/Notes/NoteCell.tsx @@ -240,7 +240,7 @@ export const NoteCell = ({ )} - {!hideDates && ( + {!note.errorDecrypting && !hideDates && ( {sortType === CollectionSort.UpdatedAt ? 'Modified ' + note.updatedAtString diff --git a/src/screens/SideMenu/NoteSideMenu.tsx b/src/screens/SideMenu/NoteSideMenu.tsx index 96e04aaa..00ea0e8d 100644 --- a/src/screens/SideMenu/NoteSideMenu.tsx +++ b/src/screens/SideMenu/NoteSideMenu.tsx @@ -3,11 +3,16 @@ import { useDeleteNoteWithPrivileges } from '@Lib/snjsHooks'; import { useFocusEffect, useNavigation } from '@react-navigation/native'; import { AppStackNavigationProp } from '@Root/App'; import { ApplicationContext } from '@Root/ApplicationContext'; -import { SCREEN_COMPOSE, SCREEN_INPUT_MODAL_TAG } from '@Screens/screens'; +import { + SCREEN_COMPOSE, + SCREEN_INPUT_MODAL_TAG, + SCREEN_NOTE_HISTORY, +} from '@Screens/screens'; import { ICON_ARCHIVE, ICON_BOOKMARK, ICON_FINGER_PRINT, + ICON_HISTORY, ICON_LOCK, ICON_MEDICAL, ICON_PRICE_TAG, @@ -455,6 +460,11 @@ export const NoteSideMenu = (props: Props) => { mutator.protected = !note.protected; }); + const openSessionHistory = () => { + props.drawerRef?.closeDrawer(); + navigation.push(SCREEN_NOTE_HISTORY, { noteUuid: note.uuid }); + }; + const shareNote = () => { if (note) { application?.getAppState().performActionWithoutStateChangeImpact(() => { @@ -471,6 +481,11 @@ export const NoteSideMenu = (props: Props) => { { text: archiveOption, onSelect: archiveEvent, icon: ICON_ARCHIVE }, { text: lockOption, onSelect: lockEvent, icon: ICON_LOCK }, { text: protectOption, onSelect: protectEvent, icon: ICON_FINGER_PRINT }, + { + text: 'Show History', + onSelect: openSessionHistory, + icon: ICON_HISTORY, + }, { text: 'Share', onSelect: shareNote, icon: ICON_SHARE }, ]; diff --git a/src/screens/screens.ts b/src/screens/screens.ts index 584d1cd2..efa86cc5 100644 --- a/src/screens/screens.ts +++ b/src/screens/screens.ts @@ -6,6 +6,7 @@ export const SCREEN_NOTES = 'Notes' as 'Notes'; export const SCREEN_COMPOSE = 'Compose' as 'Compose'; export const SCREEN_INPUT_MODAL_PASSCODE = 'InputModalPasscode' as 'InputModalPasscode'; export const SCREEN_INPUT_MODAL_TAG = 'InputModalTag' as 'InputModalTag'; +export const SCREEN_NOTE_HISTORY = 'NoteSessionHistory' as 'NoteSessionHistory'; export const SCREEN_SETTINGS = 'Settings'; export const SCREEN_MANAGE_PRIVILEGES = 'ManagePrivileges' as 'ManagePrivileges'; diff --git a/src/style/Util/getDefaultDraerWidth.ts b/src/style/Util/getDefaultDrawerWidth.ts similarity index 100% rename from src/style/Util/getDefaultDraerWidth.ts rename to src/style/Util/getDefaultDrawerWidth.ts diff --git a/src/style/icons.ts b/src/style/icons.ts index c0a746e0..43f24fcd 100644 --- a/src/style/icons.ts +++ b/src/style/icons.ts @@ -15,3 +15,4 @@ export const ICON_SHARE = 'share'; export const ICON_TRASH = 'trash'; export const ICON_USER = 'person-circle'; export const ICON_FORWARD = 'arrow-forward'; +export const ICON_HISTORY = 'time'; diff --git a/yarn.lock b/yarn.lock index 970dcab4..3c77c124 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1319,6 +1319,11 @@ resolved "https://registry.yarnpkg.com/@react-native-community/masked-view/-/masked-view-0.1.10.tgz#5dda643e19e587793bc2034dd9bf7398ad43d401" integrity sha512-rk4sWFsmtOw8oyx8SD3KSvawwaK7gRBSEIy2TAwURyGt+3TizssXP1r8nx3zY+R7v2vYYHXZ+k2/GULAT/bcaQ== +"@react-native-community/segmented-control@^2.1.1": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@react-native-community/segmented-control/-/segmented-control-2.1.1.tgz#e3a0934b79e7bd64b84215366989bb26b850ca06" + integrity sha512-vSrg+DIqX0zGeOb1o6oFLoWFFW8l1UEX/f7/9dXXzWHChF3rIqEpNHC4ONmsLJqWePN4E/n+k+q29z+GbqrqsQ== + "@react-navigation/core@^5.12.3": version "5.12.3" resolved "https://registry.yarnpkg.com/@react-navigation/core/-/core-5.12.3.tgz#0484fcca290569a0dc10b70b99f00edd3f1fd93c" @@ -6674,6 +6679,11 @@ react-native-store-review@^0.1.5: resolved "https://registry.yarnpkg.com/react-native-store-review/-/react-native-store-review-0.1.5.tgz#9df69786a137580748e368641698d2104519e4cf" integrity sha512-vVx7NYaQva3bGU5MdqXn4yEB+o+GPdmjqAuj7PnkepfeCS6Bi3sqniiKoXmKOKDgRTfIobBZjUkHzWeHli1+3A== +react-native-tab-view@^2.15.1: + version "2.15.1" + resolved "https://registry.yarnpkg.com/react-native-tab-view/-/react-native-tab-view-2.15.1.tgz#cf4df4ffdec504263a2e06a6becd8831b9a19ad9" + integrity sha512-cDYl1pNWspbEHBjHrHVpIC40h4g8VhZe0CIG0CUKcouX98vHVfAojxhgWuRosq9qMhITHpLKM+7BmBLnhCJ0nw== + react-native-vector-icons@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/react-native-vector-icons/-/react-native-vector-icons-7.0.0.tgz#5b92ed363c867645daad48c559e1f99efcfbb813"