feature: note session history

This commit is contained in:
Radek Czemerys
2020-09-02 23:37:11 +02:00
parent 1968f41c11
commit 2fe274eb3c
14 changed files with 498 additions and 3 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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}
/>
<AppStack.Screen
name={SCREEN_NOTE_HISTORY}
options={({ route }) => ({
title: 'Note history',
headerTitle: ({ children }) => {
return (
<HeaderTitleView
title={route.params?.title ?? (children || '')}
subtitle={route.params?.subTitle}
subtitleColor={route.params?.subTitleColor}
/>
);
},
})}
component={NoteHistory}
/>
</AppStack.Navigator>
</DrawerLayout>
</DrawerLayout>

View File

@@ -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;
`;

View File

@@ -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<typeof SCREEN_NOTE_HISTORY>;
export const NoteHistory = (props: Props) => {
// Context
const application = useContext(ApplicationContext);
const theme = useContext(ThemeContext);
const styleKit = useContext(StyleKitContext);
// State
const [note] = useState<SNNote>(
() => 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 <SessionHistory restoreNote={restore} note={note} />;
case 'remote':
return <RemoteHistory restoreNote={restore} note={note} />;
default:
return null;
}
};
const renderTabBar = (
tabBarProps: SceneRendererProps & {
navigationState: NavigationState<Route>;
}
) => {
return Platform.OS === 'ios' &&
parseInt(Platform.Version as string, 10) >= 13 ? (
<IosTabBarContainer>
<SegmentedControl
backgroundColor={theme.stylekitContrastBackgroundColor}
appearance={styleKit?.keyboardColorForActiveTheme()}
fontStyle={{
color: theme.stylekitForegroundColor,
}}
values={routes.map(route => route.title)}
selectedIndex={tabBarProps.navigationState.index}
onChange={event => {
setIndex(event.nativeEvent.selectedSegmentIndex);
}}
/>
</IosTabBarContainer>
) : (
<TabBar
{...tabBarProps}
indicatorStyle={{ backgroundColor: theme.stylekitInfoColor }}
inactiveColor={theme.stylekitBorderColor}
activeColor={theme.stylekitInfoColor}
style={{
backgroundColor: theme.stylekitBackgroundColor,
shadowColor: theme.stylekitShadowColor,
}}
labelStyle={{ color: theme.stylekitInfoColor }}
/>
);
};
return (
<TabView
renderTabBar={renderTabBar}
navigationState={{ index, routes }}
renderScene={renderScene}
onIndexChange={setIndex}
initialLayout={initialLayout}
/>
);
};

View File

@@ -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,
}))<TableCellProps>`
padding-top: ${12}px;
justify-content: center;
`;
const ButtonContainer = styled.View``;
type ButtonLabelProps = Pick<Props, 'disabled'>;
const ButtonLabel = styled.Text<ButtonLabelProps>`
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 => (
<Container
first={props.first}
last={props.last}
testID={props.testID}
disabled={props.disabled}
onPress={props.onPress}
>
<ButtonContainer>
<ButtonLabel disabled={props.disabled}>{props.title}</ButtonLabel>
{props.subTitle && <SubTitleText>{props.subTitle}</SubTitleText>}
</ButtonContainer>
</Container>
);

View File

@@ -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<void>;
};
export const RemoteHistory: React.FC<Props> = ({ 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<RemoteHistoryListEntry>
| null
| undefined = ({ item }) => {
return (
<NoteHistoryCell
onPress={() => onPress(item)}
title={new Date(item.updated_at).toLocaleString()}
/>
);
};
if (
fetchingRemoteHistory ||
(remoteHistoryList && remoteHistoryList.length === 0)
) {
const placeholderText = fetchingRemoteHistory
? 'Loading entries...'
: 'No entries.';
return (
<LoadingContainer>
<LoadingText>{placeholderText}</LoadingText>
</LoadingContainer>
);
}
return (
<FlatList<RemoteHistoryListEntry>
keyExtractor={item => item.uuid}
contentContainerStyle={{ paddingBottom: insets.bottom }}
initialNumToRender={10}
windowSize={10}
keyboardShouldPersistTaps={'never'}
data={remoteHistoryList}
renderItem={RenderItem}
/>
);
};

View File

@@ -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<void>;
};
export const SessionHistory: React.FC<Props> = ({ note, restoreNote }) => {
// Context
const application = useContext(ApplicationContext);
const { showActionSheet } = useCustomActionSheet();
const insets = useSafeAreaInsets();
// State
const [sessionHistory, setSessionHistory] = useState<ItemSessionHistory>();
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<NoteHistoryEntry> | null | undefined = ({
item,
}) => {
return (
<NoteHistoryCell
onPress={() => onPress(item)}
title={item.previewTitle()}
subTitle={item.previewSubTitle()}
/>
);
};
return (
<FlatList<NoteHistoryEntry>
keyExtractor={item => item.previewTitle()}
contentContainerStyle={{ paddingBottom: insets.bottom }}
initialNumToRender={10}
windowSize={10}
keyboardShouldPersistTaps={'never'}
data={sessionHistory?.entries as NoteHistoryEntry[]}
renderItem={RenderItem}
/>
);
};

View File

@@ -240,7 +240,7 @@ export const NoteCell = ({
</NoteText>
)}
{!hideDates && (
{!note.errorDecrypting && !hideDates && (
<DateText numberOfLines={1} selected={highlight}>
{sortType === CollectionSort.UpdatedAt
? 'Modified ' + note.updatedAtString

View File

@@ -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 },
];

View File

@@ -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';

View File

@@ -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';

View File

@@ -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"