diff --git a/native/types/themes.js b/lib/types/theme-types.js similarity index 71% copy from native/types/themes.js copy to lib/types/theme-types.js index 6b956926a..95fa6b5f8 100644 --- a/native/types/themes.js +++ b/lib/types/theme-types.js @@ -1,25 +1,19 @@ // @flow -import { Platform } from 'react-native'; - export type GlobalTheme = 'light' | 'dark'; export type GlobalThemePreference = GlobalTheme | 'system'; export type GlobalThemeInfo = { +activeTheme: ?GlobalTheme, +systemTheme: ?GlobalTheme, +preference: GlobalThemePreference, }; -export const osCanTheme: boolean = - (Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 13) || - (Platform.OS === 'android' && Platform.Version >= 29); - export const defaultGlobalThemeInfo = { // revert to `activeTheme: osCanTheme ? null : 'light'` to re-enable theming activeTheme: 'dark', systemTheme: null, // revert to `preference: osCanTheme ? 'system' : 'light'` // to re-enable theming preference: 'dark', }; diff --git a/native/chat/settings/delete-thread.react.js b/native/chat/settings/delete-thread.react.js index 59a3042c9..487555582 100644 --- a/native/chat/settings/delete-thread.react.js +++ b/native/chat/settings/delete-thread.react.js @@ -1,303 +1,299 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Text, View, TextInput as BaseTextInput, ActivityIndicator, } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { deleteThreadActionTypes, deleteThread, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector, containedThreadInfos, } from 'lib/selectors/thread-selectors.js'; import { identifyInvalidatedThreads, getThreadsToDeleteText, } from 'lib/shared/thread-utils.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { ThreadInfo, ResolvedThreadInfo, LeaveThreadPayload, } from 'lib/types/thread-types.js'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils.js'; import { useResolvedThreadInfo } from 'lib/utils/entity-helpers.js'; import Button from '../../components/button.react.js'; import { clearThreadsActionType } from '../../navigation/action-types.js'; import { NavContext, type NavAction, } from '../../navigation/navigation-context.js'; import type { NavigationRoute } from '../../navigation/route-names.js'; import { useSelector } from '../../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../../themes/colors.js'; -import type { GlobalTheme } from '../../types/themes.js'; import Alert from '../../utils/alert.js'; import type { ChatNavigationProp } from '../chat.react.js'; export type DeleteThreadParams = { +threadInfo: ThreadInfo, }; type BaseProps = { +navigation: ChatNavigationProp<'DeleteThread'>, +route: NavigationRoute<'DeleteThread'>, }; type Props = { ...BaseProps, // Redux state +threadInfo: ResolvedThreadInfo, +shouldUseDeleteConfirmationAlert: boolean, +loadingStatus: LoadingStatus, - +activeTheme: ?GlobalTheme, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +deleteThread: (threadID: string) => Promise, // withNavContext +navDispatch: (action: NavAction) => void, }; class DeleteThread extends React.PureComponent { mounted = false; passwordInput: ?React.ElementRef; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } guardedSetState(change, callback) { if (this.mounted) { this.setState(change, callback); } } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Delete chat ); const { threadInfo } = this.props; return ( {`The chat "${threadInfo.uiName}" will be permanently deleted. `} There is no way to reverse this. ); } passwordInputRef = ( passwordInput: ?React.ElementRef, ) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'passwordInput should be set'); this.passwordInput.focus(); }; dispatchDeleteThreadAction = () => { this.props.dispatchActionPromise( deleteThreadActionTypes, this.deleteThread(), ); }; submitDeletion = () => { if (!this.props.shouldUseDeleteConfirmationAlert) { this.dispatchDeleteThreadAction(); return; } Alert.alert( 'Warning', `${getThreadsToDeleteText( this.props.threadInfo, )} will also be permanently deleted.`, [ { text: 'Cancel', style: 'cancel' }, { text: 'Continue', onPress: this.dispatchDeleteThreadAction }, ], { cancelable: false }, ); }; async deleteThread() { const { threadInfo, navDispatch } = this.props; navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [threadInfo.id] }, }); try { const result = await this.props.deleteThread(threadInfo.id); const invalidated = identifyInvalidatedThreads( result.updatesResult.newUpdates, ); navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [...invalidated] }, }); return result; } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Permission not granted', 'You do not have permission to delete this thread', [{ text: 'OK' }], { cancelable: false }, ); } else { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }], { cancelable: false, }); } throw e; } } } const unboundStyles = { deleteButton: { backgroundColor: 'vibrantRedButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, deleteText: { color: 'white', fontSize: 18, textAlign: 'center', }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, warningText: { color: 'panelForegroundLabel', fontSize: 16, marginBottom: 24, marginHorizontal: 24, textAlign: 'center', }, }; const loadingStatusSelector = createLoadingStatusSelector( deleteThreadActionTypes, ); const ConnectedDeleteThread: React.ComponentType = React.memo(function ConnectedDeleteThread(props: BaseProps) { const threadID = props.route.params.threadInfo.id; const reduxThreadInfo = useSelector( state => threadInfoSelector(state)[threadID], ); const reduxContainedThreadInfos = useSelector( state => containedThreadInfos(state)[threadID], ); const { setParams } = props.navigation; React.useEffect(() => { if (reduxThreadInfo) { setParams({ threadInfo: reduxThreadInfo }); } }, [reduxThreadInfo, setParams]); const threadInfo = reduxThreadInfo ?? props.route.params.threadInfo; const resolvedThreadInfo = useResolvedThreadInfo(threadInfo); const loadingStatus = useSelector(loadingStatusSelector); - const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callDeleteThread = useServerCall(deleteThread); const navContext = React.useContext(NavContext); invariant(navContext, 'NavContext should be set in DeleteThread'); const navDispatch = navContext.dispatch; const shouldUseDeleteConfirmationAlert = reduxContainedThreadInfos && reduxContainedThreadInfos.length > 0; return ( ); }); export default ConnectedDeleteThread; diff --git a/native/markdown/styles.js b/native/markdown/styles.js index f7dba40f5..b271f8cef 100644 --- a/native/markdown/styles.js +++ b/native/markdown/styles.js @@ -1,107 +1,108 @@ // @flow import _memoize from 'lodash/memoize.js'; import { Platform } from 'react-native'; +import { type GlobalTheme } from 'lib/types/theme-types.js'; + import { getStylesForTheme } from '../themes/colors.js'; -import type { GlobalTheme } from '../types/themes.js'; const unboundStyles = { link: { textDecorationLine: 'underline', }, italics: { fontStyle: 'italic', }, spoiler: { color: 'spoiler', backgroundColor: 'spoiler', }, bold: { fontWeight: 'bold', }, underline: { textDecorationLine: 'underline', }, strikethrough: { textDecorationLine: 'line-through', textDecorationStyle: 'solid', }, inlineCode: { backgroundColor: 'codeBackground', fontFamily: (Platform.select({ ios: 'Menlo', default: 'monospace', }): string), fontSize: (Platform.select({ ios: 17, default: 18, }): number), }, h1: { fontSize: 32, fontWeight: 'bold', }, h2: { fontSize: 24, fontWeight: 'bold', }, h3: { fontSize: 18, fontWeight: 'bold', }, h4: { fontSize: 16, fontWeight: 'bold', }, h5: { fontSize: 13, fontWeight: 'bold', }, h6: { fontSize: 11, fontWeight: 'bold', }, blockQuote: { borderLeftWidth: 8, borderRadius: 8, padding: 10, marginBottom: 6, marginVertical: 6, }, codeBlock: { backgroundColor: 'codeBackground', padding: 10, borderRadius: 5, marginVertical: 6, }, codeBlockText: { fontFamily: (Platform.select({ ios: 'Menlo', default: 'monospace', }): string), fontSize: (Platform.select({ ios: 17, default: 18, }): number), }, listBulletStyle: { fontWeight: 'bold', }, listRow: { flexDirection: 'row', }, insideListView: { flexShrink: 1, }, }; export type MarkdownStyles = typeof unboundStyles; const getMarkdownStyles: GlobalTheme => MarkdownStyles = _memoize( (theme: GlobalTheme) => { return getStylesForTheme(unboundStyles, theme); }, ); export { getMarkdownStyles }; diff --git a/native/navigation/nav-selectors.js b/native/navigation/nav-selectors.js index 8875d33bc..c509bdc14 100644 --- a/native/navigation/nav-selectors.js +++ b/native/navigation/nav-selectors.js @@ -1,441 +1,441 @@ // @flow import type { PossiblyStaleNavigationState } from '@react-navigation/native'; import { useRoute } from '@react-navigation/native'; import _memoize from 'lodash/memoize.js'; import * as React from 'react'; import { createSelector } from 'reselect'; import { nonThreadCalendarFiltersSelector } from 'lib/selectors/calendar-filter-selectors.js'; import { currentCalendarQuery } from 'lib/selectors/nav-selectors.js'; import { useCanEditMessage } from 'lib/shared/edit-messages-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { CalendarFilter } from 'lib/types/filter-types.js'; import type { ComposableMessageInfo, RobotextMessageInfo, } from 'lib/types/message-types.js'; +import type { GlobalTheme } from 'lib/types/theme-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import type { NavContextType } from './navigation-context.js'; import { NavContext } from './navigation-context.js'; import { getStateFromNavigatorRoute, getThreadIDFromRoute, currentLeafRoute, } from './navigation-utils.js'; import { AppRouteName, TabNavigatorRouteName, MessageListRouteName, ChatRouteName, CalendarRouteName, ThreadPickerModalRouteName, ActionResultModalRouteName, accountModals, scrollBlockingModals, chatRootModals, threadRoutes, CommunityDrawerNavigatorRouteName, MessageResultsScreenRouteName, MessageSearchRouteName, } from './route-names.js'; import type { RemoveEditMode } from '../chat/message-list-types'; import { useSelector } from '../redux/redux-utils.js'; import type { NavPlusRedux } from '../types/selector-types.js'; -import type { GlobalTheme } from '../types/themes.js'; const baseCreateIsForegroundSelector = (routeName: string) => createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return false; } return navigationState.routes[navigationState.index].name === routeName; }, ); const createIsForegroundSelector: ( routeName: string, ) => (context: ?NavContextType) => boolean = _memoize( baseCreateIsForegroundSelector, ); function useIsAppLoggedIn(): boolean { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return false; } const { state } = navContext; return !accountModals.includes(state.routes[state.index].name); }, [navContext]); } const baseCreateActiveTabSelector = (routeName: string) => createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return false; } const currentRootSubroute = navigationState.routes[navigationState.index]; if (currentRootSubroute.name !== AppRouteName) { return false; } const appState = getStateFromNavigatorRoute(currentRootSubroute); const [firstAppSubroute] = appState.routes; if (firstAppSubroute.name !== CommunityDrawerNavigatorRouteName) { return false; } const communityDrawerState = getStateFromNavigatorRoute(firstAppSubroute); const [firstCommunityDrawerSubroute] = communityDrawerState.routes; if (firstCommunityDrawerSubroute.name !== TabNavigatorRouteName) { return false; } const tabState = getStateFromNavigatorRoute(firstCommunityDrawerSubroute); return tabState.routes[tabState.index].name === routeName; }, ); const createActiveTabSelector: ( routeName: string, ) => (context: ?NavContextType) => boolean = _memoize( baseCreateActiveTabSelector, ); const scrollBlockingModalsClosedSelector: ( context: ?NavContextType, ) => boolean = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return false; } const currentRootSubroute = navigationState.routes[navigationState.index]; if (currentRootSubroute.name !== AppRouteName) { return true; } const appState = getStateFromNavigatorRoute(currentRootSubroute); for (let i = appState.index; i >= 0; i--) { const route = appState.routes[i]; if (scrollBlockingModals.includes(route.name)) { return false; } } return true; }, ); function selectBackgroundIsDark( navigationState: ?PossiblyStaleNavigationState, theme: ?GlobalTheme, ): boolean { if (!navigationState) { return false; } const currentRootSubroute = navigationState.routes[navigationState.index]; if (currentRootSubroute.name !== AppRouteName) { // Very bright... we'll call it non-dark. Doesn't matter right now since // we only use this selector for determining ActionResultModal appearance return false; } const appState = getStateFromNavigatorRoute(currentRootSubroute); let appIndex = appState.index; let currentAppSubroute = appState.routes[appIndex]; while (currentAppSubroute.name === ActionResultModalRouteName) { currentAppSubroute = appState.routes[--appIndex]; } if (scrollBlockingModals.includes(currentAppSubroute.name)) { // All the scroll-blocking chat modals have a dark background return true; } return theme === 'dark'; } function activeThread( navigationState: ?PossiblyStaleNavigationState, validRouteNames: $ReadOnlyArray, ): ?string { if (!navigationState) { return null; } let rootIndex = navigationState.index; let currentRootSubroute = navigationState.routes[rootIndex]; while (currentRootSubroute.name !== AppRouteName) { if (!chatRootModals.includes(currentRootSubroute.name)) { return null; } if (rootIndex === 0) { return null; } currentRootSubroute = navigationState.routes[--rootIndex]; } const appState = getStateFromNavigatorRoute(currentRootSubroute); const [firstAppSubroute] = appState.routes; if (firstAppSubroute.name !== CommunityDrawerNavigatorRouteName) { return null; } const communityDrawerState = getStateFromNavigatorRoute(firstAppSubroute); const [firstCommunityDrawerSubroute] = communityDrawerState.routes; if (firstCommunityDrawerSubroute.name !== TabNavigatorRouteName) { return null; } const tabState = getStateFromNavigatorRoute(firstCommunityDrawerSubroute); const currentTabSubroute = tabState.routes[tabState.index]; if (currentTabSubroute.name !== ChatRouteName) { return null; } const chatState = getStateFromNavigatorRoute(currentTabSubroute); const currentChatSubroute = chatState.routes[chatState.index]; return getThreadIDFromRoute(currentChatSubroute, validRouteNames); } const activeThreadSelector: (context: ?NavContextType) => ?string = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState): ?string => activeThread(navigationState, threadRoutes), ); const messageListRouteNames = [MessageListRouteName]; const activeMessageListSelector: (context: ?NavContextType) => ?string = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState): ?string => activeThread(navigationState, messageListRouteNames), ); function useActiveThread(): ?string { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return null; } const { state } = navContext; return activeThread(state, threadRoutes); }, [navContext]); } function useActiveMessageList(): ?string { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return null; } const { state } = navContext; return activeThread(state, messageListRouteNames); }, [navContext]); } const calendarTabActiveSelector = createActiveTabSelector(CalendarRouteName); const threadPickerActiveSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const calendarActiveSelector: (context: ?NavContextType) => boolean = createSelector( calendarTabActiveSelector, threadPickerActiveSelector, (calendarTabActive: boolean, threadPickerActive: boolean) => calendarTabActive || threadPickerActive, ); const nativeCalendarQuery: (input: NavPlusRedux) => () => CalendarQuery = createSelector( (input: NavPlusRedux) => currentCalendarQuery(input.redux), (input: NavPlusRedux) => calendarActiveSelector(input.navContext), ( calendarQuery: (calendarActive: boolean) => CalendarQuery, calendarActive: boolean, ) => () => calendarQuery(calendarActive), ); const nonThreadCalendarQuery: (input: NavPlusRedux) => () => CalendarQuery = createSelector( nativeCalendarQuery, (input: NavPlusRedux) => nonThreadCalendarFiltersSelector(input.redux), ( calendarQuery: () => CalendarQuery, filters: $ReadOnlyArray, ) => { return (): CalendarQuery => { const query = calendarQuery(); return { startDate: query.startDate, endDate: query.endDate, filters, }; }; }, ); function useCalendarQuery(): () => CalendarQuery { const navContext = React.useContext(NavContext); return useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); } const drawerSwipeEnabledSelector: (context: ?NavContextType) => boolean = createSelector( (context: ?NavContextType) => context && context.state, (navigationState: ?PossiblyStaleNavigationState) => { if (!navigationState) { return true; } // First, we recurse into the navigation state until we find the tab route // The tab route should always be accessible by recursing through the // first routes of each subsequent nested navigation state const [firstRootSubroute] = navigationState.routes; if (firstRootSubroute.name !== AppRouteName) { return true; } const appState = getStateFromNavigatorRoute(firstRootSubroute); const [firstAppSubroute] = appState.routes; if (firstAppSubroute.name !== CommunityDrawerNavigatorRouteName) { return true; } const communityDrawerState = getStateFromNavigatorRoute(firstAppSubroute); const [firstCommunityDrawerSubroute] = communityDrawerState.routes; if (firstCommunityDrawerSubroute.name !== TabNavigatorRouteName) { return true; } const tabState = getStateFromNavigatorRoute(firstCommunityDrawerSubroute); // Once we have the tab state, we want to figure out if we currently have // an active StackNavigator const currentTabSubroute = tabState.routes[tabState.index]; if (!currentTabSubroute.state) { return true; } const currentTabSubrouteState = getStateFromNavigatorRoute(currentTabSubroute); if (currentTabSubrouteState.type !== 'stack') { return true; } // Finally, we want to disable the swipe gesture if there is a stack with // more than one subroute, since then the stack will have its own swipe // gesture that will conflict with the drawer's return currentTabSubrouteState.routes.length < 2; }, ); function getTabNavState( navigationState: ?PossiblyStaleNavigationState, ): ?PossiblyStaleNavigationState { if (!navigationState) { return null; } const [firstAppSubroute] = navigationState.routes; if (firstAppSubroute.name !== CommunityDrawerNavigatorRouteName) { return null; } const communityDrawerState = getStateFromNavigatorRoute(firstAppSubroute); const [firstCommunityDrawerSubroute] = communityDrawerState.routes; if (firstCommunityDrawerSubroute.name !== TabNavigatorRouteName) { return null; } const tabState = getStateFromNavigatorRoute(firstCommunityDrawerSubroute); return tabState; } function getChatNavStateFromTabNavState( tabState: ?PossiblyStaleNavigationState, ): ?PossiblyStaleNavigationState { if (!tabState) { return null; } let chatRoute; for (const route of tabState.routes) { if (route.name === ChatRouteName) { chatRoute = route; break; } } if (!chatRoute || !chatRoute.state) { return null; } const chatRouteState = getStateFromNavigatorRoute(chatRoute); if (chatRouteState.type !== 'stack') { return null; } return chatRouteState; } function getRemoveEditMode( chatRouteState: ?PossiblyStaleNavigationState, ): ?RemoveEditMode { if (!chatRouteState) { return null; } const messageListRoute = chatRouteState.routes[chatRouteState.routes.length - 1]; if (messageListRoute.name !== MessageListRouteName) { return null; } if (!messageListRoute || !messageListRoute.params) { return null; } const removeEditMode: Function = messageListRoute.params.removeEditMode; return removeEditMode; } function useCurrentLeafRouteName(): ?string { const navContext = React.useContext(NavContext); return React.useMemo(() => { if (!navContext) { return undefined; } return currentLeafRoute(navContext.state).name; }, [navContext]); } function useCanEditMessageNative( threadInfo: ThreadInfo, targetMessageInfo: ComposableMessageInfo | RobotextMessageInfo, ): boolean { const route = useRoute(); const screenKey = route.key; const threadCreationTime = threadInfo.creationTime; const messageCreationTime = targetMessageInfo.time; const canEditInThisScreen = !screenKey.startsWith(MessageSearchRouteName) && !screenKey.startsWith(MessageResultsScreenRouteName) && messageCreationTime >= threadCreationTime; return ( useCanEditMessage(threadInfo, targetMessageInfo) && canEditInThisScreen ); } export { createIsForegroundSelector, useIsAppLoggedIn, createActiveTabSelector, scrollBlockingModalsClosedSelector, selectBackgroundIsDark, activeThreadSelector, activeMessageListSelector, useActiveThread, useActiveMessageList, calendarActiveSelector, nativeCalendarQuery, nonThreadCalendarQuery, useCalendarQuery, drawerSwipeEnabledSelector, useCurrentLeafRouteName, getRemoveEditMode, getTabNavState, getChatNavStateFromTabNavState, useCanEditMessageNative, }; diff --git a/native/profile/appearance-preferences.react.js b/native/profile/appearance-preferences.react.js index e04f26497..d5a7b76d4 100644 --- a/native/profile/appearance-preferences.react.js +++ b/native/profile/appearance-preferences.react.js @@ -1,172 +1,172 @@ // @flow import * as React from 'react'; import { View, Text, Platform } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { useDispatch } from 'react-redux'; import type { Dispatch } from 'lib/types/redux-types.js'; +import type { + GlobalThemeInfo, + GlobalThemePreference, +} from 'lib/types/theme-types.js'; import Button from '../components/button.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { updateThemeInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; -import { - type GlobalThemePreference, - type GlobalThemeInfo, - osCanTheme, -} from '../types/themes.js'; +import { osCanTheme } from '../types/themes.js'; const CheckIcon = () => ( ); type OptionText = { themePreference: GlobalThemePreference, text: string, }; const optionTexts: OptionText[] = [ { themePreference: 'light', text: 'Light' }, { themePreference: 'dark', text: 'Dark' }, ]; if (osCanTheme) { optionTexts.push({ themePreference: 'system', text: 'Follow system preferences', }); } type Props = { +globalThemeInfo: GlobalThemeInfo, +styles: typeof unboundStyles, +colors: Colors, +dispatch: Dispatch, ... }; class AppearancePreferences extends React.PureComponent { render() { const { panelIosHighlightUnderlay: underlay } = this.props.colors; const options = []; for (let i = 0; i < optionTexts.length; i++) { const { themePreference, text } = optionTexts[i]; const icon = themePreference === this.props.globalThemeInfo.preference ? ( ) : null; options.push( , ); if (i + 1 < optionTexts.length) { options.push( , ); } } return ( APP THEME {options} ); } onSelectThemePreference = (themePreference: GlobalThemePreference) => { if (themePreference === this.props.globalThemeInfo.preference) { return; } const theme = themePreference === 'system' ? this.props.globalThemeInfo.systemTheme : themePreference; this.props.dispatch({ type: updateThemeInfoActionType, payload: { preference: themePreference, activeTheme: theme, }, }); }; } const unboundStyles = { header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, hr: { backgroundColor: 'panelForegroundBorder', height: 1, marginHorizontal: 15, }, icon: { lineHeight: Platform.OS === 'ios' ? 18 : 20, }, option: { color: 'panelForegroundLabel', fontSize: 16, }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 10, }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 2, }, }; const ConnectedAppearancePreferences: React.ComponentType<{ ... }> = React.memo<{ ... }>(function ConnectedAppearancePreferences(props: { ... }) { const globalThemeInfo = useSelector(state => state.globalThemeInfo); const styles = useStyles(unboundStyles); const colors = useColors(); const dispatch = useDispatch(); return ( ); }); export default ConnectedAppearancePreferences; diff --git a/native/profile/edit-password.react.js b/native/profile/edit-password.react.js index f93463cd4..bc9cd186d 100644 --- a/native/profile/edit-password.react.js +++ b/native/profile/edit-password.react.js @@ -1,375 +1,371 @@ // @flow import { CommonActions } from '@react-navigation/native'; import invariant from 'invariant'; import * as React from 'react'; import { Text, View, TextInput as BaseTextInput, ActivityIndicator, } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; import { changeUserPasswordActionTypes, changeUserPassword, } from 'lib/actions/user-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { PasswordUpdate } from 'lib/types/user-types.js'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils.js'; import type { ProfileNavigationProp } from './profile.react.js'; import { setNativeCredentials } from '../account/native-credentials.js'; import Button from '../components/button.react.js'; import TextInput from '../components/text-input.react.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useColors, useStyles } from '../themes/colors.js'; -import type { GlobalTheme } from '../types/themes.js'; import Alert from '../utils/alert.js'; type BaseProps = { +navigation: ProfileNavigationProp<'EditPassword'>, +route: NavigationRoute<'EditPassword'>, }; type Props = { ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +username: ?string, - +activeTheme: ?GlobalTheme, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeUserPassword: (passwordUpdate: PasswordUpdate) => Promise, }; type State = { +currentPassword: string, +newPassword: string, +confirmPassword: string, }; class EditPassword extends React.PureComponent { state: State = { currentPassword: '', newPassword: '', confirmPassword: '', }; mounted = false; currentPasswordInput: ?React.ElementRef; newPasswordInput: ?React.ElementRef; confirmPasswordInput: ?React.ElementRef; componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Save ); const { panelForegroundTertiaryLabel } = this.props.colors; return ( CURRENT PASSWORD NEW PASSWORD ); } onChangeCurrentPassword = (currentPassword: string) => { this.setState({ currentPassword }); }; currentPasswordRef = ( currentPasswordInput: ?React.ElementRef, ) => { this.currentPasswordInput = currentPasswordInput; }; focusCurrentPassword = () => { invariant(this.currentPasswordInput, 'currentPasswordInput should be set'); this.currentPasswordInput.focus(); }; onChangeNewPassword = (newPassword: string) => { this.setState({ newPassword }); }; newPasswordRef = ( newPasswordInput: ?React.ElementRef, ) => { this.newPasswordInput = newPasswordInput; }; focusNewPassword = () => { invariant(this.newPasswordInput, 'newPasswordInput should be set'); this.newPasswordInput.focus(); }; onChangeConfirmPassword = (confirmPassword: string) => { this.setState({ confirmPassword }); }; confirmPasswordRef = ( confirmPasswordInput: ?React.ElementRef, ) => { this.confirmPasswordInput = confirmPasswordInput; }; focusConfirmPassword = () => { invariant(this.confirmPasswordInput, 'confirmPasswordInput should be set'); this.confirmPasswordInput.focus(); }; goBackOnce() { this.props.navigation.dispatch(state => ({ ...CommonActions.goBack(), target: state.key, })); } submitPassword = () => { if (this.state.newPassword === '') { Alert.alert( 'Empty password', 'New password cannot be empty', [{ text: 'OK', onPress: this.onNewPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (this.state.newPassword !== this.state.confirmPassword) { Alert.alert( 'Passwords don’t match', 'New password fields must contain the same password', [{ text: 'OK', onPress: this.onNewPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (this.state.newPassword === this.state.currentPassword) { this.goBackOnce(); } else { this.props.dispatchActionPromise( changeUserPasswordActionTypes, this.savePassword(), ); } }; async savePassword() { const { username } = this.props; if (!username) { return; } try { await this.props.changeUserPassword({ updatedFields: { password: this.state.newPassword, }, currentPassword: this.state.currentPassword, }); await setNativeCredentials({ username, password: this.state.newPassword, }); this.goBackOnce(); } catch (e) { if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect password', 'The current password you entered is incorrect', [{ text: 'OK', onPress: this.onCurrentPasswordAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } } } onNewPasswordAlertAcknowledged = () => { this.setState( { newPassword: '', confirmPassword: '' }, this.focusNewPassword, ); }; onCurrentPasswordAlertAcknowledged = () => { this.setState({ currentPassword: '' }, this.focusCurrentPassword); }; onUnknownErrorAlertAcknowledged = () => { this.setState( { currentPassword: '', newPassword: '', confirmPassword: '' }, this.focusCurrentPassword, ); }; } const unboundStyles = { header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, hr: { backgroundColor: 'panelForegroundBorder', height: 1, marginHorizontal: 15, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, row: { flexDirection: 'row', justifyContent: 'space-between', paddingHorizontal: 24, paddingVertical: 9, }, saveButton: { backgroundColor: 'vibrantGreenButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, saveText: { color: 'white', fontSize: 18, textAlign: 'center', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, marginBottom: 24, paddingVertical: 3, }, }; const loadingStatusSelector = createLoadingStatusSelector( changeUserPasswordActionTypes, ); const ConnectedEditPassword: React.ComponentType = React.memo(function ConnectedEditPassword(props: BaseProps) { const loadingStatus = useSelector(loadingStatusSelector); const username = useSelector(state => { if (state.currentUserInfo && !state.currentUserInfo.anonymous) { return state.currentUserInfo.username; } return undefined; }); - const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callChangeUserPassword = useServerCall(changeUserPassword); return ( ); }); export default ConnectedEditPassword; diff --git a/native/push/in-app-notif.react.js b/native/push/in-app-notif.react.js index 182534ec0..699902797 100644 --- a/native/push/in-app-notif.react.js +++ b/native/push/in-app-notif.react.js @@ -1,93 +1,94 @@ // @flow import * as React from 'react'; import { View, StyleSheet, Platform } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import type { GlobalTheme } from 'lib/types/theme-types.js'; + import SingleLine from '../components/single-line.react.js'; -import type { GlobalTheme } from '../types/themes.js'; const edges = ['top']; type Props = { +title: ?string, +message: string, +activeTheme: ?GlobalTheme, }; function InAppNotif(props: Props): React.Node { const useLightStyle = Platform.OS === 'ios' && props.activeTheme !== 'dark'; let title = null; const textStyle = [ styles.text, useLightStyle ? styles.lightText : styles.darkText, ]; if (props.title) { title = ( {props.title} ); } const notificationContent = ( {title} {props.message} ); if (Platform.OS === 'android') { return ( {notificationContent} ); } return {notificationContent}; } const styles = StyleSheet.create({ darkText: { color: 'black', }, lightText: { color: 'white', }, notif: { alignItems: 'flex-start', alignSelf: 'flex-start', justifyContent: 'flex-start', width: '100%', }, notificationContent: { ...Platform.select({ ios: { marginTop: 16, marginBottom: 6, }, default: { marginVertical: 16, }, }), marginHorizontal: 10, }, text: { ...Platform.select({ ios: { fontSize: 16, color: 'black', }, default: { fontSize: 18, }, }), }, title: { fontWeight: 'bold', }, }); export default InAppNotif; diff --git a/native/push/push-handler.react.js b/native/push/push-handler.react.js index 4f92d25b4..0d65f520c 100644 --- a/native/push/push-handler.react.js +++ b/native/push/push-handler.react.js @@ -1,682 +1,682 @@ // @flow import * as Haptics from 'expo-haptics'; import invariant from 'invariant'; import * as React from 'react'; import { Platform, LogBox } from 'react-native'; import { Notification as InAppNotification } from 'react-native-in-app-message'; import { useDispatch } from 'react-redux'; import { setDeviceTokenActionTypes, setDeviceToken, } from 'lib/actions/device-actions.js'; import { saveMessagesActionType } from 'lib/actions/message-actions.js'; import { updatesCurrentAsOfSelector, connectionSelector, } from 'lib/selectors/keyserver-selectors.js'; import { unreadCount, threadInfoSelector, } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ConnectionInfo } from 'lib/types/socket-types.js'; +import type { GlobalTheme } from 'lib/types/theme-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils.js'; import { convertNotificationMessageInfoToNewIDSchema, convertNonPendingIDToNewSchema, } from 'lib/utils/migration-utils.js'; import { type NotifPermissionAlertInfo, recordNotifPermissionAlertActionType, shouldSkipPushPermissionAlert, } from 'lib/utils/push-alerts.js'; import sleep from 'lib/utils/sleep.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { parseAndroidMessage, androidNotificationChannelID, handleAndroidMessage, getCommAndroidNotificationsEventEmitter, type AndroidMessage, CommAndroidNotifications, } from './android.js'; import { CommIOSNotification, type CoreIOSNotificationData, type CoreIOSNotificationDataWithRequestIdentifier, } from './comm-ios-notification.js'; import InAppNotif from './in-app-notif.react.js'; import { requestIOSPushPermissions, iosPushPermissionResponseReceived, CommIOSNotifications, getCommIOSNotificationsEventEmitter, } from './ios.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import { addLifecycleListener, getCurrentLifecycleState, } from '../lifecycle/lifecycle.js'; import { replaceWithThreadActionType } from '../navigation/action-types.js'; import { activeMessageListSelector } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { RootContext, type RootContextType } from '../root-context.js'; import type { EventSubscription } from '../types/react-native.js'; -import { type GlobalTheme } from '../types/themes.js'; import Alert from '../utils/alert.js'; LogBox.ignoreLogs([ // react-native-in-app-message 'ForceTouchGestureHandler is not available', ]); type BaseProps = { +navigation: RootNavigationProp<'App'>, }; type Props = { ...BaseProps, // Navigation state +activeThread: ?string, // Redux state +unreadCount: number, +deviceToken: ?string, +threadInfos: { +[id: string]: ThreadInfo }, +notifPermissionAlertInfo: NotifPermissionAlertInfo, +connection: ConnectionInfo, +updatesCurrentAsOf: number, +activeTheme: ?GlobalTheme, +loggedIn: boolean, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +setDeviceToken: (deviceToken: ?string) => Promise, // withRootContext +rootContext: ?RootContextType, }; type State = { +inAppNotifProps: ?{ +customComponent: React.Node, +blurType: ?('xlight' | 'dark'), +onPress: () => void, }, }; class PushHandler extends React.PureComponent { state: State = { inAppNotifProps: null, }; currentState: ?string = getCurrentLifecycleState(); appStarted = 0; androidNotificationsEventSubscriptions: Array = []; androidNotificationsPermissionPromise: ?Promise = undefined; initialAndroidNotifHandled = false; openThreadOnceReceived: Set = new Set(); lifecycleSubscription: ?EventSubscription; iosNotificationEventSubscriptions: Array = []; componentDidMount() { this.appStarted = Date.now(); this.lifecycleSubscription = addLifecycleListener( this.handleAppStateChange, ); this.onForeground(); if (Platform.OS === 'ios') { const commIOSNotificationsEventEmitter = getCommIOSNotificationsEventEmitter(); this.iosNotificationEventSubscriptions.push( commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTERED_EVENT, registration => this.registerPushPermissions(registration?.deviceToken), ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTRATION_FAILED_EVENT, this.failedToRegisterPushPermissionsIOS, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_FOREGROUND_EVENT, this.iosForegroundNotificationReceived, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants().NOTIFICATION_OPENED_EVENT, this.iosNotificationOpened, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_BACKGROUND_EVENT, this.iosBackgroundNotificationReceived, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.createChannel( androidNotificationChannelID, 'Default', CommAndroidNotifications.getConstants().NOTIFICATIONS_IMPORTANCE_HIGH, 'Comm notifications channel', ); const commAndroidNotificationsEventEmitter = getCommAndroidNotificationsEventEmitter(); this.androidNotificationsEventSubscriptions.push( commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_TOKEN, this.handleAndroidDeviceToken, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_MESSAGE, this.androidMessageReceived, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED, this.androidNotificationOpened, ), ); } if (this.props.connection.status === 'connected') { this.updateBadgeCount(); } } componentWillUnmount() { if (this.lifecycleSubscription) { this.lifecycleSubscription.remove(); } if (Platform.OS === 'ios') { for (const iosNotificationEventSubscription of this .iosNotificationEventSubscriptions) { iosNotificationEventSubscription.remove(); } } else if (Platform.OS === 'android') { for (const androidNotificationsEventSubscription of this .androidNotificationsEventSubscriptions) { androidNotificationsEventSubscription.remove(); } this.androidNotificationsEventSubscriptions = []; } } handleAppStateChange = (nextState: ?string) => { if (!nextState || nextState === 'unknown') { return; } const lastState = this.currentState; this.currentState = nextState; if (lastState === 'background' && nextState === 'active') { this.onForeground(); this.clearNotifsOfThread(); } }; onForeground() { if (this.props.loggedIn) { this.ensurePushNotifsEnabled(); } else if (this.props.deviceToken) { // We do this in case there was a crash, so we can clear deviceToken from // any other cookies it might be set for this.setDeviceToken(this.props.deviceToken); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.activeThread !== prevProps.activeThread) { this.clearNotifsOfThread(); } if ( this.props.connection.status === 'connected' && (prevProps.connection.status !== 'connected' || this.props.unreadCount !== prevProps.unreadCount) ) { this.updateBadgeCount(); } for (const threadID of this.openThreadOnceReceived) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, false); this.openThreadOnceReceived.clear(); break; } } if ( (this.props.loggedIn && !prevProps.loggedIn) || (!this.props.deviceToken && prevProps.deviceToken) ) { this.ensurePushNotifsEnabled(); } if (!this.props.loggedIn && prevProps.loggedIn) { this.clearAllNotifs(); } if ( this.state.inAppNotifProps && this.state.inAppNotifProps !== prevState.inAppNotifProps ) { Haptics.notificationAsync(); InAppNotification.show(); } } updateBadgeCount() { const curUnreadCount = this.props.unreadCount; if (Platform.OS === 'ios') { CommIOSNotifications.setBadgesCount(curUnreadCount); } else if (Platform.OS === 'android') { CommAndroidNotifications.setBadge(curUnreadCount); } } clearAllNotifs() { if (Platform.OS === 'ios') { CommIOSNotifications.removeAllDeliveredNotifications(); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllDeliveredNotifications(); } } clearNotifsOfThread() { const { activeThread } = this.props; if (!activeThread) { return; } if (Platform.OS === 'ios') { CommIOSNotifications.getDeliveredNotifications(notifications => PushHandler.clearDeliveredIOSNotificationsForThread( activeThread, notifications, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllActiveNotificationsForThread( activeThread, ); } } static clearDeliveredIOSNotificationsForThread( threadID: string, notifications: $ReadOnlyArray, ) { const identifiersToClear = []; for (const notification of notifications) { if (notification.threadID === threadID) { identifiersToClear.push(notification.identifier); } } if (identifiersToClear) { CommIOSNotifications.removeDeliveredNotifications(identifiersToClear); } } async ensurePushNotifsEnabled() { if (!this.props.loggedIn) { return; } if (Platform.OS === 'ios') { const missingDeviceToken = this.props.deviceToken === null || this.props.deviceToken === undefined; await requestIOSPushPermissions(missingDeviceToken); } else if (Platform.OS === 'android') { await this.ensureAndroidPushNotifsEnabled(); } } async ensureAndroidPushNotifsEnabled() { const permissionPromisesResult = await Promise.all([ CommAndroidNotifications.hasPermission(), CommAndroidNotifications.canRequestNotificationsPermissionFromUser(), ]); let [hasPermission] = permissionPromisesResult; const [, canRequestPermission] = permissionPromisesResult; if (!hasPermission && canRequestPermission) { const permissionResponse = await (async () => { // We issue a call to sleep to match iOS behavior where prompt // doesn't appear immediately but after logged-out modal disappears await sleep(10); await this.requestAndroidNotificationsPermission(); })(); hasPermission = permissionResponse; } if (!hasPermission) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); return; } try { const fcmToken = await CommAndroidNotifications.getToken(); await this.handleAndroidDeviceToken(fcmToken); } catch (e) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); } } requestAndroidNotificationsPermission = () => { if (!this.androidNotificationsPermissionPromise) { this.androidNotificationsPermissionPromise = (async () => { const notifPermission = await CommAndroidNotifications.requestNotificationsPermission(); this.androidNotificationsPermissionPromise = undefined; return notifPermission; })(); } return this.androidNotificationsPermissionPromise; }; handleAndroidDeviceToken = async (deviceToken: string) => { this.registerPushPermissions(deviceToken); await this.handleInitialAndroidNotification(); }; async handleInitialAndroidNotification() { if (this.initialAndroidNotifHandled) { return; } this.initialAndroidNotifHandled = true; const initialNotifThreadID = await CommAndroidNotifications.getInitialNotificationThreadID(); if (initialNotifThreadID) { await this.androidNotificationOpened(initialNotifThreadID); } } registerPushPermissions = (deviceToken: ?string) => { const deviceType = Platform.OS; if (deviceType !== 'android' && deviceType !== 'ios') { return; } if (deviceType === 'ios') { iosPushPermissionResponseReceived(); } if (deviceToken !== this.props.deviceToken) { this.setDeviceToken(deviceToken); } }; setDeviceToken(deviceToken: ?string) { this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceToken(deviceToken), ); } failedToRegisterPushPermissionsIOS = () => { this.setDeviceToken(null); if (!this.props.loggedIn) { return; } iosPushPermissionResponseReceived(); }; failedToRegisterPushPermissionsAndroid = ( shouldShowAlertOnAndroid: boolean, ) => { this.setDeviceToken(null); if (!this.props.loggedIn) { return; } if (shouldShowAlertOnAndroid) { this.showNotifAlertOnAndroid(); } }; showNotifAlertOnAndroid() { const alertInfo = this.props.notifPermissionAlertInfo; if (shouldSkipPushPermissionAlert(alertInfo)) { return; } this.props.dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); Alert.alert( 'Unable to initialize notifs!', 'Please check your network connection, make sure Google Play ' + 'services are installed and enabled, and confirm that your Google ' + 'Play credentials are valid in the Google Play Store.', undefined, { cancelable: true }, ); } navigateToThread(threadInfo: ThreadInfo, clearChatRoutes: boolean) { if (clearChatRoutes) { this.props.navigation.dispatch({ type: replaceWithThreadActionType, payload: { threadInfo }, }); } else { this.props.navigateToThread({ threadInfo }); } } onPressNotificationForThread(threadID: string, clearChatRoutes: boolean) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, clearChatRoutes); } else { this.openThreadOnceReceived.add(threadID); } } saveMessageInfos(rawMessageInfos: ?$ReadOnlyArray) { if (!rawMessageInfos) { return; } const { updatesCurrentAsOf } = this.props; this.props.dispatch({ type: saveMessagesActionType, payload: { rawMessageInfos, updatesCurrentAsOf }, }); } iosForegroundNotificationReceived = ( rawNotification: CoreIOSNotificationData, ) => { const notification = new CommIOSNotification(rawNotification); if (Date.now() < this.appStarted + 1500) { // On iOS, when the app is opened from a notif press, for some reason this // callback gets triggered before iosNotificationOpened. In fact this // callback shouldn't be triggered at all. To avoid weirdness we are // ignoring any foreground notification received within the first second // of the app being started, since they are most likely to be erroneous. notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NO_DATA, ); return; } const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); let title = notification.getData().title; let body = notification.getData().body; if (title && body) { ({ title, body } = mergePrefixIntoBody({ title, body })); } else { body = notification.getMessage(); } if (body) { this.showInAppNotification(threadID, body, title); } else { console.log( 'Non-rescind foreground notification without alert received!', ); } notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; iosBackgroundNotificationReceived = backgroundData => { const convertedMessageInfos = backgroundData.messageInfosArray .flatMap(convertNotificationMessageInfoToNewIDSchema) .filter(Boolean); if (!convertedMessageInfos.length) { return; } this.saveMessageInfos(convertedMessageInfos); }; onPushNotifBootsApp() { if ( this.props.rootContext && this.props.rootContext.detectUnsupervisedBackground ) { this.props.rootContext.detectUnsupervisedBackground(false); } } iosNotificationOpened = (rawNotification: CoreIOSNotificationData) => { const notification = new CommIOSNotification(rawNotification); this.onPushNotifBootsApp(); const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); this.onPressNotificationForThread(threadID, true); notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; showInAppNotification(threadID: string, message: string, title?: ?string) { if (threadID === this.props.activeThread) { return; } this.setState({ inAppNotifProps: { customComponent: ( ), blurType: this.props.activeTheme === 'dark' ? 'xlight' : 'dark', onPress: () => { InAppNotification.hide(); this.onPressNotificationForThread(threadID, false); }, }, }); } androidNotificationOpened = async (threadID: string) => { const convertedThreadID = convertNonPendingIDToNewSchema( threadID, ashoatKeyserverID, ); this.onPushNotifBootsApp(); this.onPressNotificationForThread(convertedThreadID, true); }; androidMessageReceived = async (message: AndroidMessage) => { const parsedMessage = parseAndroidMessage(message); this.onPushNotifBootsApp(); const { messageInfos } = parsedMessage; this.saveMessageInfos(messageInfos); handleAndroidMessage( parsedMessage, this.props.updatesCurrentAsOf, this.handleAndroidNotificationIfActive, ); }; handleAndroidNotificationIfActive = ( threadID: string, texts: { body: string, title: ?string }, ) => { if (this.currentState !== 'active') { return false; } this.showInAppNotification(threadID, texts.body, texts.title); return true; }; render() { return ( ); } } const ConnectedPushHandler: React.ComponentType = React.memo(function ConnectedPushHandler(props: BaseProps) { const navContext = React.useContext(NavContext); const activeThread = activeMessageListSelector(navContext); const boundUnreadCount = useSelector(unreadCount); const deviceToken = useSelector(state => state.deviceToken); const threadInfos = useSelector(threadInfoSelector); const notifPermissionAlertInfo = useSelector( state => state.notifPermissionAlertInfo, ); const connection = useSelector(connectionSelector); invariant(connection, 'keyserver missing from keyserverStore'); const updatesCurrentAsOf = useSelector(updatesCurrentAsOfSelector); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const loggedIn = useSelector(isLoggedIn); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const boundSetDeviceToken = useServerCall(setDeviceToken); const rootContext = React.useContext(RootContext); return ( ); }); export default ConnectedPushHandler; diff --git a/native/redux/action-types.js b/native/redux/action-types.js index eb94d5a9c..d221c8c21 100644 --- a/native/redux/action-types.js +++ b/native/redux/action-types.js @@ -1,66 +1,66 @@ // @flow import type { Orientations } from 'react-native-orientation-locker'; import { saveMessagesActionType } from 'lib/actions/message-actions.js'; import type { Shape } from 'lib/types/core.js'; import type { BaseAction } from 'lib/types/redux-types.js'; +import type { GlobalThemeInfo } from 'lib/types/theme-types.js'; import type { DimensionsInfo } from './dimensions-updater.react.js'; import type { AppState } from './state-types.js'; import type { DeviceCameraInfo } from '../types/camera.js'; import type { ConnectivityInfo } from '../types/connectivity.js'; import type { LocalSettings } from '../types/local-settings-types.js'; -import type { GlobalThemeInfo } from '../types/themes.js'; export const updateDimensionsActiveType = 'UPDATE_DIMENSIONS'; export const updateConnectivityActiveType = 'UPDATE_CONNECTIVITY'; export const updateThemeInfoActionType = 'UPDATE_THEME_INFO'; export const updateDeviceCameraInfoActionType = 'UPDATE_DEVICE_CAMERA_INFO'; export const updateDeviceOrientationActionType = 'UPDATE_DEVICE_ORIENTATION'; export const setStoreLoadedActionType = 'SET_STORE_LOADED'; export const setReduxStateActionType = 'SET_REDUX_STATE'; export const setLocalSettingsActionType = 'SET_LOCAL_SETTINGS'; export const backgroundActionTypes: Set = new Set([ saveMessagesActionType, ]); export type Action = | BaseAction | { +type: 'SET_REDUX_STATE', +payload: { +state: AppState, +hideFromMonitor: boolean }, } | { +type: 'SET_CUSTOM_SERVER', +payload: string, } | { +type: 'UPDATE_DIMENSIONS', +payload: Shape, } | { +type: 'UPDATE_CONNECTIVITY', +payload: ConnectivityInfo, } | { +type: 'UPDATE_THEME_INFO', +payload: Shape, } | { +type: 'UPDATE_DEVICE_CAMERA_INFO', +payload: Shape, } | { +type: 'UPDATE_DEVICE_ORIENTATION', +payload: Orientations, } | { +type: 'UPDATE_THREAD_LAST_NAVIGATED', +payload: { +threadID: string, +time: number }, } | { +type: 'SET_STORE_LOADED', } | { +type: 'SET_LOCAL_SETTINGS', +payload: LocalSettings }; diff --git a/native/redux/default-state.js b/native/redux/default-state.js index d5b03c4c1..acfcf023c 100644 --- a/native/redux/default-state.js +++ b/native/redux/default-state.js @@ -1,91 +1,91 @@ // @flow import { Platform } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { defaultEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarQuery } from 'lib/types/entry-types.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import { defaultConnectionInfo } from 'lib/types/socket-types.js'; +import { defaultGlobalThemeInfo } from 'lib/types/theme-types.js'; import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { defaultDimensionsInfo } from './dimensions-updater.react.js'; import type { AppState } from './state-types.js'; import { defaultNavInfo } from '../navigation/default-state.js'; import { defaultDeviceCameraInfo } from '../types/camera.js'; import { defaultConnectivityInfo } from '../types/connectivity.js'; -import { defaultGlobalThemeInfo } from '../types/themes.js'; import { defaultURLPrefix, natNodeServer } from '../utils/url-utils.js'; const defaultState = ({ navInfo: defaultNavInfo, currentUserInfo: null, draftStore: { drafts: {} }, entryStore: { entryInfos: {}, daysToEntries: {}, lastUserInteractionCalendar: 0, }, threadStore: { threadInfos: {}, }, userStore: { userInfos: {}, }, messageStore: { messages: {}, threads: {}, local: {}, currentAsOf: { [ashoatKeyserverID]: 0 }, }, storeLoaded: false, loadingStatuses: {}, calendarFilters: defaultCalendarFilters, deviceToken: null, dataLoaded: false, customServer: natNodeServer, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, actualizedCalendarQuery: defaultCalendarQuery(Platform.OS), watchedThreadIDs: [], lifecycleState: 'active', enabledApps: defaultEnabledApps, reportStore: { enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, queuedReports: [], }, nextLocalID: 0, _persist: null, dimensions: defaultDimensionsInfo, connectivity: defaultConnectivityInfo, globalThemeInfo: defaultGlobalThemeInfo, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), frozen: false, userPolicies: {}, commServicesAccessToken: null, inviteLinksStore: { links: {}, }, keyserverStore: { keyserverInfos: { [ashoatKeyserverID]: { updatesCurrentAsOf: 0, urlPrefix: defaultURLPrefix, connection: defaultConnectionInfo, lastCommunicatedPlatformDetails: null, }, }, }, localSettings: { isBackupEnabled: false, }, threadActivityStore: {}, integrityStore: { threadHashes: {}, threadHashingStatus: 'starting' }, }: AppState); export { defaultState }; diff --git a/native/redux/persist.js b/native/redux/persist.js index 984541896..d08d83fe6 100644 --- a/native/redux/persist.js +++ b/native/redux/persist.js @@ -1,992 +1,992 @@ // @flow import AsyncStorage from '@react-native-async-storage/async-storage'; import invariant from 'invariant'; import { Platform } from 'react-native'; import Orientation from 'react-native-orientation-locker'; import { createTransform } from 'redux-persist'; import type { Transform } from 'redux-persist/es/types.js'; import { convertEntryStoreToNewIDSchema, convertInviteLinksStoreToNewIDSchema, convertMessageStoreToNewIDSchema, convertRawMessageInfoToNewIDSchema, convertCalendarFilterToNewIDSchema, convertConnectionInfoToNewIDSchema, } from 'lib/_generated/migration-utils.js'; import { type ClientDBMessageStoreOperation, messageStoreOpsHandlers, } from 'lib/ops/message-store-ops.js'; import { type ReportStoreOperation, type ClientDBReportStoreOperation, convertReportsToReplaceReportOps, reportStoreOpsHandlers, } from 'lib/ops/report-store-ops.js'; import type { ClientDBThreadStoreOperation } from 'lib/ops/thread-store-ops.js'; import { threadStoreOpsHandlers } from 'lib/ops/thread-store-ops.js'; import { highestLocalIDSelector } from 'lib/selectors/local-id-selectors.js'; import { createAsyncMigrate } from 'lib/shared/create-async-migrate.js'; import { inconsistencyResponsesToReports } from 'lib/shared/report-utils.js'; import { getContainingThreadID, getCommunity, } from 'lib/shared/thread-utils.js'; import { DEPRECATED_unshimMessageStore, unshimFunc, } from 'lib/shared/unshim-utils.js'; import { defaultEnabledApps } from 'lib/types/enabled-apps.js'; import { defaultCalendarQuery } from 'lib/types/entry-types.js'; import { defaultCalendarFilters } from 'lib/types/filter-types.js'; import type { KeyserverStore, KeyserverInfo, } from 'lib/types/keyserver-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { type LocalMessageInfo, type MessageStore, type MessageStoreThreads, } from 'lib/types/message-types.js'; import type { ReportStore, ClientReportCreationRequest, } from 'lib/types/report-types.js'; import { defaultConnectionInfo, type ConnectionInfo, } from 'lib/types/socket-types.js'; +import { defaultGlobalThemeInfo } from 'lib/types/theme-types.js'; import type { ClientDBThreadInfo } from 'lib/types/thread-types.js'; import { translateClientDBMessageInfoToRawMessageInfo, translateRawMessageInfoToClientDBMessageInfo, } from 'lib/utils/message-ops-utils.js'; import { generateIDSchemaMigrationOpsForDrafts, convertMessageStoreThreadsToNewIDSchema, convertThreadStoreThreadInfosToNewIDSchema, } from 'lib/utils/migration-utils.js'; import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import { convertClientDBThreadInfoToRawThreadInfo, convertRawThreadInfoToClientDBThreadInfo, } from 'lib/utils/thread-ops-utils.js'; import { getUUID } from 'lib/utils/uuid.js'; import { ashoatKeyserverID } from 'lib/utils/validation-utils.js'; import { updateClientDBThreadStoreThreadInfos, createUpdateDBOpsForThreadStoreThreadInfos, createUpdateDBOpsForMessageStoreMessages, createUpdateDBOpsForMessageStoreThreads, } from './client-db-utils.js'; import { defaultState } from './default-state.js'; import { migrateThreadStoreForEditThreadPermissions } from './edit-thread-permission-migration.js'; import { persistMigrationForManagePinsThreadPermission } from './manage-pins-permission-migration.js'; import type { AppState } from './state-types.js'; import { unshimClientDB } from './unshim-utils.js'; import { updateRolesAndPermissions } from './update-roles-and-permissions.js'; import { commCoreModule } from '../native-modules.js'; import { defaultDeviceCameraInfo } from '../types/camera.js'; -import { defaultGlobalThemeInfo } from '../types/themes.js'; import { isTaskCancelledError } from '../utils/error-handling.js'; const migrations = { [1]: (state: AppState) => ({ ...state, notifPermissionAlertInfo: defaultNotifPermissionAlertInfo, }), [2]: (state: AppState) => ({ ...state, messageSentFromRoute: [], }), [3]: state => ({ currentUserInfo: state.currentUserInfo, entryStore: state.entryStore, threadInfos: state.threadInfos, userInfos: state.userInfos, messageStore: { ...state.messageStore, currentAsOf: state.currentAsOf, }, updatesCurrentAsOf: state.currentAsOf, cookie: state.cookie, deviceToken: state.deviceToken, urlPrefix: state.urlPrefix, customServer: state.customServer, notifPermissionAlertInfo: state.notifPermissionAlertInfo, messageSentFromRoute: state.messageSentFromRoute, _persist: state._persist, }), [4]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, }), [5]: (state: AppState) => ({ ...state, calendarFilters: defaultCalendarFilters, }), [6]: state => ({ ...state, threadInfos: undefined, threadStore: { threadInfos: state.threadInfos, inconsistencyResponses: [], }, }), [7]: state => ({ ...state, lastUserInteraction: undefined, sessionID: undefined, entryStore: { ...state.entryStore, inconsistencyResponses: [], }, }), [8]: (state: AppState) => ({ ...state, pingTimestamps: undefined, activeServerRequests: undefined, connection: { ...defaultConnectionInfo, actualizedCalendarQuery: defaultCalendarQuery(Platform.OS), }, watchedThreadIDs: [], entryStore: { ...state.entryStore, actualizedCalendarQuery: undefined, }, }), [9]: state => ({ ...state, connection: { ...state.connection, lateResponses: [], }, }), [10]: state => ({ ...state, nextLocalID: highestLocalIDSelector(state) + 1, connection: { ...state.connection, showDisconnectedBar: false, }, messageStore: { ...state.messageStore, local: {}, }, }), [11]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.IMAGES, ]), }), [12]: (state: AppState) => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [13]: (state: AppState) => ({ ...state, deviceCameraInfo: defaultDeviceCameraInfo, deviceOrientation: Orientation.getInitialOrientation(), }), [14]: (state: AppState) => state, [15]: state => ({ ...state, threadStore: { ...state.threadStore, inconsistencyReports: inconsistencyResponsesToReports( state.threadStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: inconsistencyResponsesToReports( state.entryStore.inconsistencyResponses, ), inconsistencyResponses: undefined, }, queuedReports: [], }), [16]: state => { const result = { ...state, messageSentFromRoute: undefined, dataLoaded: !!state.currentUserInfo && !state.currentUserInfo.anonymous, }; if (state.navInfo) { result.navInfo = { ...state.navInfo, navigationState: undefined, }; } return result; }, [17]: state => ({ ...state, userInfos: undefined, userStore: { userInfos: state.userInfos, inconsistencyResponses: [], }, }), [18]: state => ({ ...state, userStore: { userInfos: state.userStore.userInfos, inconsistencyReports: [], }, }), [19]: state => { const threadInfos = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const { visibilityRules, ...rest } = threadInfo; threadInfos[threadID] = rest; } return { ...state, threadStore: { ...state.threadStore, threadInfos, }, }; }, [20]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.UPDATE_RELATIONSHIP, ]), }), [21]: (state: AppState) => ({ ...state, messageStore: DEPRECATED_unshimMessageStore(state.messageStore, [ messageTypes.CREATE_SIDEBAR, messageTypes.SIDEBAR_SOURCE, ]), }), [22]: state => { for (const key in state.drafts) { const value = state.drafts[key]; try { commCoreModule.updateDraft(key, value); } catch (e) { if (!isTaskCancelledError(e)) { throw e; } } } return { ...state, drafts: undefined, }; }, [23]: state => ({ ...state, globalThemeInfo: defaultGlobalThemeInfo, }), [24]: state => ({ ...state, enabledApps: defaultEnabledApps, }), [25]: state => ({ ...state, crashReportsEnabled: __DEV__, }), [26]: state => { const { currentUserInfo } = state; if (currentUserInfo.anonymous) { return state; } return { ...state, crashReportsEnabled: undefined, currentUserInfo: { id: currentUserInfo.id, username: currentUserInfo.username, }, enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, }; }, [27]: state => ({ ...state, queuedReports: undefined, enabledReports: undefined, threadStore: { ...state.threadStore, inconsistencyReports: undefined, }, entryStore: { ...state.entryStore, inconsistencyReports: undefined, }, reportStore: { enabledReports: { crashReports: __DEV__, inconsistencyReports: __DEV__, mediaReports: __DEV__, }, queuedReports: [ ...state.entryStore.inconsistencyReports, ...state.threadStore.inconsistencyReports, ...state.queuedReports, ], }, }), [28]: state => { const threadParentToChildren = {}; for (const threadID in state.threadStore.threadInfos) { const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? state.threadStore.threadInfos[threadInfo.parentThreadID] : null; const parentIndex = parentThreadInfo ? parentThreadInfo.id : '-1'; if (!threadParentToChildren[parentIndex]) { threadParentToChildren[parentIndex] = []; } threadParentToChildren[parentIndex].push(threadID); } const rootIDs = threadParentToChildren['-1']; if (!rootIDs) { // This should never happen, but if it somehow does we'll let the state // check mechanism resolve it... return state; } const threadInfos = {}; const stack = [...rootIDs]; while (stack.length > 0) { const threadID = stack.shift(); const threadInfo = state.threadStore.threadInfos[threadID]; const parentThreadInfo = threadInfo.parentThreadID ? threadInfos[threadInfo.parentThreadID] : null; threadInfos[threadID] = { ...threadInfo, containingThreadID: getContainingThreadID( parentThreadInfo, threadInfo.type, ), community: getCommunity(parentThreadInfo), }; const children = threadParentToChildren[threadID]; if (children) { stack.push(...children); } } return { ...state, threadStore: { ...state.threadStore, threadInfos } }; }, [29]: (state: AppState) => { const updatedThreadInfos = migrateThreadStoreForEditThreadPermissions( state.threadStore.threadInfos, ); return { ...state, threadStore: { ...state.threadStore, threadInfos: updatedThreadInfos, }, }; }, [30]: (state: AppState) => { const threadInfos = state.threadStore.threadInfos; const operations = [ { type: 'remove_all', }, ...Object.keys(threadInfos).map((id: string) => ({ type: 'replace', payload: { id, threadInfo: threadInfos[id] }, })), ]; try { commCoreModule.processThreadStoreOperationsSync( threadStoreOpsHandlers.convertOpsToClientDBOps(operations), ); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [31]: (state: AppState) => { const messages = state.messageStore.messages; const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...Object.keys(messages).map((id: string) => ({ type: 'replace', payload: translateRawMessageInfoToClientDBMessageInfo(messages[id]), })), ]; try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.log(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [32]: (state: AppState) => unshimClientDB(state, [messageTypes.MULTIMEDIA]), [33]: (state: AppState) => unshimClientDB(state, [messageTypes.REACTION]), [34]: state => { const { threadIDsToNotifIDs, ...stateSansThreadIDsToNotifIDs } = state; return stateSansThreadIDsToNotifIDs; }, [35]: (state: AppState) => unshimClientDB(state, [messageTypes.MULTIMEDIA]), [36]: (state: AppState) => { // 1. Get threads and messages from SQLite `threads` and `messages` tables. const clientDBThreadInfos = commCoreModule.getAllThreadsSync(); const clientDBMessageInfos = commCoreModule.getAllMessagesSync(); // 2. Translate `ClientDBThreadInfo`s to `RawThreadInfo`s and // `ClientDBMessageInfo`s to `RawMessageInfo`s. const rawThreadInfos = clientDBThreadInfos.map( convertClientDBThreadInfoToRawThreadInfo, ); const rawMessageInfos = clientDBMessageInfos.map( translateClientDBMessageInfoToRawMessageInfo, ); // 3. Unshim translated `RawMessageInfos` to get the TOGGLE_PIN messages const unshimmedRawMessageInfos = rawMessageInfos.map(messageInfo => unshimFunc(messageInfo, new Set([messageTypes.TOGGLE_PIN])), ); // 4. Filter out non-TOGGLE_PIN messages const filteredRawMessageInfos = unshimmedRawMessageInfos.filter( messageInfo => messageInfo.type === messageTypes.TOGGLE_PIN, ); // 5. We want only the last TOGGLE_PIN message for each message ID, // so 'pin', 'unpin', 'pin' don't count as 3 pins, but only 1. const lastMessageIDToRawMessageInfoMap = new Map(); for (const messageInfo of filteredRawMessageInfos) { const { targetMessageID } = messageInfo; lastMessageIDToRawMessageInfoMap.set(targetMessageID, messageInfo); } const lastMessageIDToRawMessageInfos = Array.from( lastMessageIDToRawMessageInfoMap.values(), ); // 6. Create a Map of threadIDs to pinnedCount const threadIDsToPinnedCount = new Map(); for (const messageInfo of lastMessageIDToRawMessageInfos) { const { threadID, type } = messageInfo; if (type === messageTypes.TOGGLE_PIN) { const pinnedCount = threadIDsToPinnedCount.get(threadID) || 0; threadIDsToPinnedCount.set(threadID, pinnedCount + 1); } } // 7. Include a pinnedCount for each rawThreadInfo const rawThreadInfosWithPinnedCount = rawThreadInfos.map(threadInfo => ({ ...threadInfo, pinnedCount: threadIDsToPinnedCount.get(threadInfo.id) || 0, })); // 8. Convert rawThreadInfos to a map of threadID to threadInfo const threadIDToThreadInfo = rawThreadInfosWithPinnedCount.reduce( (acc, threadInfo) => { acc[threadInfo.id] = threadInfo; return acc; }, {}, ); // 9. Add threadPermission to each threadInfo const rawThreadInfosWithThreadPermission = persistMigrationForManagePinsThreadPermission(threadIDToThreadInfo); // 10. Convert the new threadInfos back into an array const rawThreadInfosWithCountAndPermission = Object.keys( rawThreadInfosWithThreadPermission, ).map(id => rawThreadInfosWithThreadPermission[id]); // 11. Translate `RawThreadInfo`s to `ClientDBThreadInfo`s. const convertedClientDBThreadInfos = rawThreadInfosWithCountAndPermission.map( convertRawThreadInfoToClientDBThreadInfo, ); // 12. Construct `ClientDBThreadStoreOperation`s to clear SQLite `threads` // table and repopulate with `ClientDBThreadInfo`s. const operations: $ReadOnlyArray = [ { type: 'remove_all', }, ...convertedClientDBThreadInfos.map((thread: ClientDBThreadInfo) => ({ type: 'replace', payload: thread, })), ]; // 13. Try processing `ClientDBThreadStoreOperation`s and log out if // `processThreadStoreOperationsSync(...)` throws an exception. try { commCoreModule.processThreadStoreOperationsSync(operations); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } return state; }, [37]: state => { const operations = messageStoreOpsHandlers.convertOpsToClientDBOps([ { type: 'remove_all_threads', }, { type: 'replace_threads', payload: { threads: state.messageStore.threads }, }, ]); try { commCoreModule.processMessageStoreOperationsSync(operations); } catch (exception) { console.error(exception); if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [38]: state => updateClientDBThreadStoreThreadInfos(state, updateRolesAndPermissions), [39]: (state: AppState) => unshimClientDB(state, [messageTypes.EDIT_MESSAGE]), [40]: state => updateClientDBThreadStoreThreadInfos(state, updateRolesAndPermissions), [41]: (state: AppState) => { const queuedReports = state.reportStore.queuedReports.map(report => ({ ...report, id: getUUID(), })); return { ...state, reportStore: { ...state.reportStore, queuedReports }, }; }, [42]: (state: AppState) => { const reportStoreOperations: $ReadOnlyArray = [ { type: 'remove_all_reports' }, ...convertReportsToReplaceReportOps(state.reportStore.queuedReports), ]; const dbOperations: $ReadOnlyArray = reportStoreOpsHandlers.convertOpsToClientDBOps(reportStoreOperations); try { commCoreModule.processReportStoreOperationsSync(dbOperations); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } return state; }, [43]: async state => { const { messages, drafts, threads, messageStoreThreads } = await commCoreModule.getClientDBStore(); const messageStoreThreadsOperations = createUpdateDBOpsForMessageStoreThreads( messageStoreThreads, convertMessageStoreThreadsToNewIDSchema, ); const messageStoreMessagesOperations = createUpdateDBOpsForMessageStoreMessages(messages, messageInfos => messageInfos.map(convertRawMessageInfoToNewIDSchema), ); const threadOperations = createUpdateDBOpsForThreadStoreThreadInfos( threads, convertThreadStoreThreadInfosToNewIDSchema, ); const draftOperations = generateIDSchemaMigrationOpsForDrafts(drafts); try { await Promise.all([ commCoreModule.processMessageStoreOperations([ ...messageStoreMessagesOperations, ...messageStoreThreadsOperations, ]), commCoreModule.processThreadStoreOperations(threadOperations), commCoreModule.processDraftStoreOperations(draftOperations), ]); } catch (exception) { console.log(exception); return { ...state, cookie: null }; } const inviteLinksStore = state.inviteLinksStore ?? defaultState.inviteLinksStore; return { ...state, entryStore: convertEntryStoreToNewIDSchema(state.entryStore), messageStore: convertMessageStoreToNewIDSchema(state.messageStore), calendarFilters: state.calendarFilters.map( convertCalendarFilterToNewIDSchema, ), connection: convertConnectionInfoToNewIDSchema(state.connection), watchedThreadIDs: state.watchedThreadIDs.map( id => `${ashoatKeyserverID}|${id}`, ), inviteLinksStore: convertInviteLinksStoreToNewIDSchema(inviteLinksStore), }; }, [44]: async state => { const { cookie, ...rest } = state; return { ...rest, keyserverStore: { keyserverInfos: { [ashoatKeyserverID]: { cookie } } }, }; }, [45]: async state => { const { updatesCurrentAsOf, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...keyserverStore.keyserverInfos[ashoatKeyserverID], updatesCurrentAsOf, }, }, }, }; }, [46]: async state => { const { currentAsOf } = state.messageStore; return { ...state, messageStore: { ...state.messageStore, currentAsOf: { [ashoatKeyserverID]: currentAsOf }, }, }; }, [47]: async state => { const { urlPrefix, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...keyserverStore.keyserverInfos[ashoatKeyserverID], urlPrefix, }, }, }, }; }, [48]: async state => { const { connection, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...keyserverStore.keyserverInfos[ashoatKeyserverID], connection, }, }, }, }; }, [49]: async state => { const { keyserverStore, ...rest } = state; const { connection, ...keyserverRest } = keyserverStore.keyserverInfos[ashoatKeyserverID]; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...keyserverRest, }, }, }, connection, }; }, [50]: async state => { const { connection, ...rest } = state; const { actualizedCalendarQuery, ...connectionRest } = connection; return { ...rest, connection: connectionRest, actualizedCalendarQuery, }; }, [51]: async state => { const { lastCommunicatedPlatformDetails, keyserverStore, ...rest } = state; return { ...rest, keyserverStore: { ...keyserverStore, keyserverInfos: { ...keyserverStore.keyserverInfos, [ashoatKeyserverID]: { ...keyserverStore.keyserverInfos[ashoatKeyserverID], lastCommunicatedPlatformDetails, }, }, }, }; }, [52]: async state => ({ ...state, integrityStore: { threadHashes: {}, threadHashingStatus: 'data_not_loaded', }, }), [53]: state => { if (!state.userStore.inconsistencyReports) { return state; } const reportStoreOperations = convertReportsToReplaceReportOps( state.userStore.inconsistencyReports, ); const dbOperations: $ReadOnlyArray = reportStoreOpsHandlers.convertOpsToClientDBOps(reportStoreOperations); try { commCoreModule.processReportStoreOperationsSync(dbOperations); } catch (exception) { if (isTaskCancelledError(exception)) { return state; } return { ...state, cookie: null }; } const { inconsistencyReports, ...newUserStore } = state.userStore; const queuedReports = reportStoreOpsHandlers.processStoreOperations( state.reportStore.queuedReports, reportStoreOperations, ); return { ...state, userStore: newUserStore, reportStore: { ...state.reportStore, queuedReports, }, }; }, [54]: state => { let updatedMessageStoreThreads: MessageStoreThreads = {}; for (const threadID: string in state.messageStore.threads) { const { lastNavigatedTo, lastPruned, ...rest } = state.messageStore.threads[threadID]; updatedMessageStoreThreads = { ...updatedMessageStoreThreads, [threadID]: rest, }; } return { ...state, messageStore: { ...state.messageStore, threads: updatedMessageStoreThreads, }, }; }, }; // After migration 31, we'll no longer want to persist `messageStore.messages` // via redux-persist. However, we DO want to continue persisting everything in // `messageStore` EXCEPT for `messages`. The `blacklist` property in // `persistConfig` allows us to specify top-level keys that shouldn't be // persisted. However, we aren't able to specify nested keys in `blacklist`. // As a result, if we want to prevent nested keys from being persisted we'll // need to use `createTransform(...)` to specify an `inbound` function that // allows us to modify the `state` object before it's passed through // `JSON.stringify(...)` and written to disk. We specify the keys for which // this transformation should be executed in the `whitelist` property of the // `config` object that's passed to `createTransform(...)`. // eslint-disable-next-line no-unused-vars type PersistedThreadMessageInfo = { +startReached: boolean, }; type PersistedMessageStore = { +local: { +[id: string]: LocalMessageInfo }, +currentAsOf: { +[keyserverID: string]: number }, +threads: { +[threadID: string]: PersistedThreadMessageInfo }, }; const messageStoreMessagesBlocklistTransform: Transform = createTransform( (state: MessageStore): PersistedMessageStore => { const { messages, threads, ...messageStoreSansMessages } = state; // We also do not want to persist `messageStore.threads[ID].messageIDs` // because they can be deterministically computed based on messages we have // from SQLite const threadsToPersist = {}; for (const threadID in threads) { const { messageIDs, ...threadsData } = threads[threadID]; threadsToPersist[threadID] = threadsData; } return { ...messageStoreSansMessages, threads: threadsToPersist }; }, (state: MessageStore): MessageStore => { const { threads: persistedThreads, ...messageStore } = state; const threads = {}; for (const threadID in persistedThreads) { threads[threadID] = { ...persistedThreads[threadID], messageIDs: [] }; } // We typically expect `messageStore.messages` to be `undefined` because // messages are persisted in the SQLite `messages` table rather than via // `redux-persist`. In this case we want to set `messageStore.messages` // to {} so we don't run into issues with `messageStore.messages` being // `undefined` (https://phab.comm.dev/D5545). // // However, in the case that a user is upgrading from a client where // `persistConfig.version` < 31, we expect `messageStore.messages` to // contain messages stored via `redux-persist` that we need in order // to correctly populate the SQLite `messages` table in migration 31 // (https://phab.comm.dev/D2600). // // However, because `messageStoreMessagesBlocklistTransform` modifies // `messageStore` before migrations are run, we need to make sure we aren't // inadvertently clearing `messageStore.messages` (by setting to {}) before // messages are stored in SQLite (https://linear.app/comm/issue/ENG-2377). return { ...messageStore, threads, messages: messageStore.messages ?? {} }; }, { whitelist: ['messageStore'] }, ); type PersistedReportStore = $Diff< ReportStore, { +queuedReports: $ReadOnlyArray }, >; const reportStoreTransform: Transform = createTransform( (state: ReportStore): PersistedReportStore => { return { enabledReports: state.enabledReports }; }, (state: PersistedReportStore): ReportStore => { return { ...state, queuedReports: [] }; }, { whitelist: ['reportStore'] }, ); type PersistedKeyserverInfo = $Diff< KeyserverInfo, { +connection: ConnectionInfo }, >; type PersistedKeyserverStore = { +keyserverInfos: { +[key: string]: PersistedKeyserverInfo }, }; const keyserverStoreTransform: Transform = createTransform( (state: KeyserverStore): PersistedKeyserverStore => { const keyserverInfos = {}; for (const key in state.keyserverInfos) { const { connection, ...rest } = state.keyserverInfos[key]; keyserverInfos[key] = rest; } return { ...state, keyserverInfos, }; }, (state: PersistedKeyserverStore): KeyserverStore => { const keyserverInfos = {}; const defaultConnection = defaultConnectionInfo; for (const key in state.keyserverInfos) { keyserverInfos[key] = { ...state.keyserverInfos[key], connection: { ...defaultConnection }, }; } return { ...state, keyserverInfos, }; }, { whitelist: ['keyserverStore'] }, ); const persistConfig = { key: 'root', storage: AsyncStorage, blacklist: [ 'loadingStatuses', 'lifecycleState', 'dimensions', 'draftStore', 'connectivity', 'deviceOrientation', 'frozen', 'threadStore', 'storeLoaded', 'connection', ], debug: __DEV__, version: 54, transforms: [ messageStoreMessagesBlocklistTransform, reportStoreTransform, keyserverStoreTransform, ], migrate: (createAsyncMigrate(migrations, { debug: __DEV__ }): any), timeout: ((__DEV__ ? 0 : undefined): number | void), }; const codeVersion: number = commCoreModule.getCodeVersion(); // This local exists to avoid a circular dependency where redux-setup needs to // import all the navigation and screen stuff, but some of those screens want to // access the persistor to purge its state. let storedPersistor = null; function setPersistor(persistor: *) { storedPersistor = persistor; } function getPersistor(): empty { invariant(storedPersistor, 'should be set'); return storedPersistor; } export { persistConfig, codeVersion, setPersistor, getPersistor }; diff --git a/native/redux/state-types.js b/native/redux/state-types.js index 24c9cc2dc..af8278221 100644 --- a/native/redux/state-types.js +++ b/native/redux/state-types.js @@ -1,65 +1,65 @@ // @flow import type { Orientations } from 'react-native-orientation-locker'; import type { PersistState } from 'redux-persist/es/types.js'; import type { DraftStore } from 'lib/types/draft-types.js'; import type { EnabledApps } from 'lib/types/enabled-apps.js'; import type { EntryStore, CalendarQuery } from 'lib/types/entry-types.js'; import type { CalendarFilter } from 'lib/types/filter-types.js'; import type { IntegrityStore } from 'lib/types/integrity-types.js'; import type { KeyserverStore } from 'lib/types/keyserver-types.js'; import type { LifecycleState } from 'lib/types/lifecycle-state-types.js'; import type { InviteLinksStore } from 'lib/types/link-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { MessageStore } from 'lib/types/message-types.js'; import type { UserPolicies } from 'lib/types/policy-types.js'; import type { ReportStore } from 'lib/types/report-types.js'; +import type { GlobalThemeInfo } from 'lib/types/theme-types.js'; import type { ThreadActivityStore } from 'lib/types/thread-activity-types'; import type { ThreadStore } from 'lib/types/thread-types.js'; import type { CurrentUserInfo, UserStore } from 'lib/types/user-types.js'; import type { NotifPermissionAlertInfo } from 'lib/utils/push-alerts.js'; import type { DimensionsInfo } from './dimensions-updater.react.js'; import type { NavInfo } from '../navigation/default-state.js'; import type { DeviceCameraInfo } from '../types/camera.js'; import type { ConnectivityInfo } from '../types/connectivity.js'; import type { LocalSettings } from '../types/local-settings-types.js'; -import type { GlobalThemeInfo } from '../types/themes.js'; export type AppState = { +navInfo: NavInfo, +currentUserInfo: ?CurrentUserInfo, +draftStore: DraftStore, +entryStore: EntryStore, +threadStore: ThreadStore, +userStore: UserStore, +messageStore: MessageStore, +storeLoaded: boolean, +loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, +calendarFilters: $ReadOnlyArray, +deviceToken: ?string, +dataLoaded: boolean, +customServer: ?string, +notifPermissionAlertInfo: NotifPermissionAlertInfo, +actualizedCalendarQuery: CalendarQuery, +watchedThreadIDs: $ReadOnlyArray, +lifecycleState: LifecycleState, +enabledApps: EnabledApps, +reportStore: ReportStore, +nextLocalID: number, +_persist: ?PersistState, +dimensions: DimensionsInfo, +connectivity: ConnectivityInfo, +globalThemeInfo: GlobalThemeInfo, +deviceCameraInfo: DeviceCameraInfo, +deviceOrientation: Orientations, +frozen: boolean, +userPolicies: UserPolicies, +commServicesAccessToken: ?string, +inviteLinksStore: InviteLinksStore, +keyserverStore: KeyserverStore, +threadActivityStore: ThreadActivityStore, +localSettings: LocalSettings, +integrityStore: IntegrityStore, }; diff --git a/native/redux/theme-reducer.js b/native/redux/theme-reducer.js index 5561103a3..def1d2114 100644 --- a/native/redux/theme-reducer.js +++ b/native/redux/theme-reducer.js @@ -1,47 +1,47 @@ // @flow import { siweAuthActionTypes } from 'lib/actions/siwe-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, } from 'lib/actions/user-actions.js'; +import { + defaultGlobalThemeInfo, + type GlobalThemeInfo, +} from 'lib/types/theme-types.js'; import { setNewSessionActionType } from 'lib/utils/action-utils.js'; import { updateThemeInfoActionType, type Action } from './action-types.js'; -import { - type GlobalThemeInfo, - defaultGlobalThemeInfo, -} from '../types/themes.js'; export default function reduceGlobalThemeInfo( state: GlobalThemeInfo, action: Action, ): GlobalThemeInfo { if ( action.type === logInActionTypes.success || action.type === siweAuthActionTypes.success || action.type === registerActionTypes.success ) { return defaultGlobalThemeInfo; } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo && action.payload.sessionChange.currentUserInfo.anonymous ) { return defaultGlobalThemeInfo; } else if ( action.type === logOutActionTypes.started || action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success ) { return defaultGlobalThemeInfo; } else if (action.type === updateThemeInfoActionType) { return { ...state, ...action.payload, }; } return state; } diff --git a/native/themes/colors.js b/native/themes/colors.js index 24dee8962..0f895da8c 100644 --- a/native/themes/colors.js +++ b/native/themes/colors.js @@ -1,381 +1,382 @@ // @flow import * as React from 'react'; import { StyleSheet } from 'react-native'; import { createSelector } from 'reselect'; +import type { GlobalTheme } from 'lib/types/theme-types.js'; + import { selectBackgroundIsDark } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { useSelector } from '../redux/redux-utils.js'; import type { AppState } from '../redux/state-types.js'; -import type { GlobalTheme } from '../types/themes.js'; const designSystemColors = Object.freeze({ shadesWhite100: '#ffffff', shadesWhite90: '#f5f5f5', shadesWhite80: '#ebebeb', shadesWhite70: '#e0e0e0', shadesWhite60: '#cccccc', shadesBlack100: '#0a0a0a', shadesBlack90: '#1f1f1f', shadesBlack80: '#404040', shadesBlack70: '#666666', shadesBlack60: '#808080', violetDark100: '#7e57c2', violetDark80: '#6d49ab', violetDark60: '#563894', violetDark40: '#44297a', violetDark20: '#331f5c', violetLight100: '#ae94db', violetLight80: '#b9a4df', violetLight60: '#d3c6ec', violetLight40: '#e8e0f5', violetLight20: '#f3f0fa', successLight10: '#d5f6e3', successLight50: '#6cdf9c', successPrimary: '#00c853', successDark50: '#029841', successDark90: '#034920', errorLight10: '#feebe6', errorLight50: '#f9947b', errorPrimary: '#f53100', errorDark50: '#b62602', errorDark90: '#4f1203', spoilerColor: '#33332c', }); const light = Object.freeze({ blockQuoteBackground: designSystemColors.shadesWhite70, blockQuoteBorder: designSystemColors.shadesWhite60, codeBackground: designSystemColors.shadesWhite70, disabledButton: designSystemColors.shadesWhite70, disabledButtonText: designSystemColors.shadesBlack60, disconnectedBarBackground: designSystemColors.shadesWhite90, editButton: '#A4A4A2', floatingButtonBackground: '#999999', floatingButtonLabel: designSystemColors.shadesWhite80, headerChevron: designSystemColors.shadesBlack100, inlineEngagementBackground: designSystemColors.shadesWhite70, inlineEngagementLabel: designSystemColors.shadesBlack100, link: designSystemColors.violetDark100, listBackground: designSystemColors.shadesWhite100, listBackgroundLabel: designSystemColors.shadesBlack100, listBackgroundSecondaryLabel: '#444444', listBackgroundTernaryLabel: '#999999', listChatBubble: '#F1F0F5', listForegroundLabel: designSystemColors.shadesBlack100, listForegroundSecondaryLabel: '#333333', listForegroundTertiaryLabel: designSystemColors.shadesBlack70, listInputBackground: designSystemColors.shadesWhite90, listInputBar: '#E2E2E2', listInputButton: '#8E8D92', listIosHighlightUnderlay: '#DDDDDDDD', listSearchBackground: designSystemColors.shadesWhite90, listSearchIcon: '#8E8D92', listSeparatorLabel: designSystemColors.shadesBlack70, modalBackground: designSystemColors.shadesWhite80, modalBackgroundLabel: '#333333', modalBackgroundSecondaryLabel: '#AAAAAA', modalButton: '#BBBBBB', modalButtonLabel: designSystemColors.shadesBlack100, modalContrastBackground: designSystemColors.shadesBlack100, modalContrastForegroundLabel: designSystemColors.shadesWhite100, modalContrastOpacity: 0.7, modalForeground: designSystemColors.shadesWhite100, modalForegroundBorder: designSystemColors.shadesWhite60, modalForegroundLabel: designSystemColors.shadesBlack100, modalForegroundSecondaryLabel: '#888888', modalForegroundTertiaryLabel: '#AAAAAA', modalIosHighlightUnderlay: '#CCCCCCDD', modalSubtext: designSystemColors.shadesWhite60, modalSubtextLabel: designSystemColors.shadesBlack70, modalInputBackground: designSystemColors.shadesWhite60, modalInputForeground: designSystemColors.shadesWhite90, modalKnob: designSystemColors.shadesWhite90, navigationCard: designSystemColors.shadesWhite100, navigationChevron: designSystemColors.shadesWhite60, panelBackground: designSystemColors.shadesWhite90, panelBackgroundLabel: '#888888', panelButton: designSystemColors.shadesWhite70, panelForeground: designSystemColors.shadesWhite100, panelForegroundBorder: designSystemColors.shadesWhite60, panelForegroundLabel: designSystemColors.shadesBlack100, panelForegroundSecondaryLabel: '#333333', panelForegroundTertiaryLabel: '#888888', panelInputBackground: designSystemColors.shadesWhite60, panelInputSecondaryForeground: designSystemColors.shadesBlack60, panelIosHighlightUnderlay: '#EBEBEBDD', panelSecondaryForeground: designSystemColors.shadesWhite80, panelSecondaryForegroundBorder: designSystemColors.shadesWhite70, panelSeparator: designSystemColors.shadesWhite60, purpleLink: designSystemColors.violetDark100, purpleButton: designSystemColors.violetDark100, reactionSelectionPopoverItemBackground: designSystemColors.shadesBlack80, redText: designSystemColors.errorPrimary, spoiler: designSystemColors.spoilerColor, tabBarAccent: designSystemColors.violetDark100, tabBarBackground: designSystemColors.shadesWhite90, tabBarActiveTintColor: designSystemColors.violetDark100, vibrantGreenButton: designSystemColors.successPrimary, vibrantRedButton: designSystemColors.errorPrimary, whiteText: designSystemColors.shadesWhite100, tooltipBackground: designSystemColors.shadesWhite70, logInSpacer: '#FFFFFF33', siweButton: designSystemColors.shadesWhite100, siweButtonText: designSystemColors.shadesBlack90, drawerExpandButton: designSystemColors.shadesBlack60, drawerExpandButtonDisabled: designSystemColors.shadesWhite60, drawerItemLabelLevel0: designSystemColors.shadesBlack100, drawerItemLabelLevel1: designSystemColors.shadesBlack100, drawerItemLabelLevel2: designSystemColors.shadesBlack90, drawerOpenCommunityBackground: designSystemColors.shadesWhite90, drawerBackground: designSystemColors.shadesWhite100, subthreadsModalClose: designSystemColors.shadesBlack60, subthreadsModalBackground: designSystemColors.shadesWhite80, subthreadsModalSearch: '#00000008', messageLabel: designSystemColors.shadesBlack100, modalSeparator: designSystemColors.shadesWhite60, secondaryButtonBorder: designSystemColors.shadesWhite100, inviteLinkLinkColor: designSystemColors.shadesBlack100, inviteLinkButtonBackground: designSystemColors.shadesWhite60, }); export type Colors = $Exact; const dark: Colors = Object.freeze({ blockQuoteBackground: '#A9A9A9', blockQuoteBorder: designSystemColors.shadesBlack60, codeBackground: designSystemColors.shadesBlack100, disabledButton: designSystemColors.shadesBlack80, disabledButtonText: designSystemColors.shadesBlack60, disconnectedBarBackground: designSystemColors.shadesBlack90, editButton: designSystemColors.shadesBlack70, floatingButtonBackground: designSystemColors.shadesBlack70, floatingButtonLabel: designSystemColors.shadesWhite100, headerChevron: designSystemColors.shadesWhite100, inlineEngagementBackground: designSystemColors.shadesBlack70, inlineEngagementLabel: designSystemColors.shadesWhite100, link: designSystemColors.violetLight100, listBackground: designSystemColors.shadesBlack100, listBackgroundLabel: designSystemColors.shadesWhite60, listBackgroundSecondaryLabel: '#BBBBBB', listBackgroundTernaryLabel: designSystemColors.shadesBlack60, listChatBubble: '#26252A', listForegroundLabel: designSystemColors.shadesWhite100, listForegroundSecondaryLabel: designSystemColors.shadesWhite60, listForegroundTertiaryLabel: designSystemColors.shadesBlack60, listInputBackground: designSystemColors.shadesBlack90, listInputBar: designSystemColors.shadesBlack70, listInputButton: designSystemColors.shadesWhite60, listIosHighlightUnderlay: '#BBBBBB88', listSearchBackground: designSystemColors.shadesBlack90, listSearchIcon: designSystemColors.shadesWhite60, listSeparatorLabel: designSystemColors.shadesWhite80, modalBackground: designSystemColors.shadesBlack100, modalBackgroundLabel: designSystemColors.shadesWhite60, modalBackgroundSecondaryLabel: designSystemColors.shadesBlack70, modalButton: designSystemColors.shadesBlack70, modalButtonLabel: designSystemColors.shadesWhite100, modalContrastBackground: designSystemColors.shadesWhite100, modalContrastForegroundLabel: designSystemColors.shadesBlack100, modalContrastOpacity: 0.85, modalForeground: designSystemColors.shadesBlack90, modalForegroundBorder: designSystemColors.shadesBlack90, modalForegroundLabel: designSystemColors.shadesWhite100, modalForegroundSecondaryLabel: '#AAAAAA', modalForegroundTertiaryLabel: designSystemColors.shadesBlack70, modalIosHighlightUnderlay: '#AAAAAA88', modalSubtext: designSystemColors.shadesBlack80, modalSubtextLabel: '#AAAAAA', modalInputBackground: designSystemColors.shadesBlack80, modalInputForeground: designSystemColors.shadesBlack60, modalKnob: designSystemColors.shadesWhite90, navigationCard: '#2A2A2A', navigationChevron: designSystemColors.shadesBlack70, panelBackground: designSystemColors.shadesBlack100, panelBackgroundLabel: designSystemColors.shadesWhite60, panelButton: designSystemColors.shadesBlack70, panelForeground: designSystemColors.shadesBlack90, panelForegroundBorder: '#2C2C2E', panelForegroundLabel: designSystemColors.shadesWhite100, panelForegroundSecondaryLabel: designSystemColors.shadesWhite60, panelForegroundTertiaryLabel: '#AAAAAA', panelInputBackground: designSystemColors.shadesBlack80, panelInputSecondaryForeground: designSystemColors.shadesBlack60, panelIosHighlightUnderlay: '#313035', panelSecondaryForeground: designSystemColors.shadesBlack80, panelSecondaryForegroundBorder: designSystemColors.shadesBlack70, panelSeparator: designSystemColors.shadesBlack80, purpleLink: designSystemColors.violetLight100, purpleButton: designSystemColors.violetDark100, reactionSelectionPopoverItemBackground: designSystemColors.shadesBlack80, redText: designSystemColors.errorPrimary, spoiler: designSystemColors.spoilerColor, tabBarAccent: designSystemColors.violetLight100, tabBarBackground: designSystemColors.shadesBlack100, tabBarActiveTintColor: designSystemColors.violetLight100, vibrantGreenButton: designSystemColors.successPrimary, vibrantRedButton: designSystemColors.errorPrimary, whiteText: designSystemColors.shadesWhite100, tooltipBackground: designSystemColors.shadesBlack90, logInSpacer: '#FFFFFF33', siweButton: designSystemColors.shadesWhite100, siweButtonText: designSystemColors.shadesBlack90, drawerExpandButton: designSystemColors.shadesBlack60, drawerExpandButtonDisabled: designSystemColors.shadesBlack80, drawerItemLabelLevel0: designSystemColors.shadesWhite60, drawerItemLabelLevel1: designSystemColors.shadesWhite60, drawerItemLabelLevel2: designSystemColors.shadesWhite90, drawerOpenCommunityBackground: '#191919', drawerBackground: designSystemColors.shadesBlack90, subthreadsModalClose: designSystemColors.shadesBlack60, subthreadsModalBackground: designSystemColors.shadesBlack90, subthreadsModalSearch: '#FFFFFF04', typeaheadTooltipBackground: '#1F1F1f', typeaheadTooltipBorder: designSystemColors.shadesBlack80, typeaheadTooltipText: 'white', messageLabel: designSystemColors.shadesWhite60, modalSeparator: designSystemColors.shadesBlack80, secondaryButtonBorder: designSystemColors.shadesWhite100, inviteLinkLinkColor: designSystemColors.shadesWhite80, inviteLinkButtonBackground: designSystemColors.shadesBlack80, }); const colors = { light, dark }; const colorsSelector: (state: AppState) => Colors = createSelector( (state: AppState) => state.globalThemeInfo.activeTheme, (theme: ?GlobalTheme) => { const explicitTheme = theme ? theme : 'light'; return colors[explicitTheme]; }, ); const magicStrings = new Set(); for (const theme in colors) { for (const magicString in colors[theme]) { magicStrings.add(magicString); } } type Styles = { [name: string]: { [field: string]: mixed } }; type ReplaceField = (input: any) => any; export type StyleSheetOf = $ObjMap; function stylesFromColors( obj: IS, themeColors: Colors, ): StyleSheetOf { const result = {}; for (const key in obj) { const style = obj[key]; const filledInStyle = { ...style }; for (const styleKey in style) { const styleValue = style[styleKey]; if (typeof styleValue !== 'string') { continue; } if (magicStrings.has(styleValue)) { const mapped = themeColors[styleValue]; if (mapped) { filledInStyle[styleKey] = mapped; } } } result[key] = filledInStyle; } return StyleSheet.create(result); } function styleSelector( obj: IS, ): (state: AppState) => StyleSheetOf { return createSelector(colorsSelector, (themeColors: Colors) => stylesFromColors(obj, themeColors), ); } function useStyles(obj: IS): StyleSheetOf { const ourColors = useColors(); return React.useMemo( () => stylesFromColors(obj, ourColors), [obj, ourColors], ); } function useOverlayStyles(obj: IS): StyleSheetOf { const navContext = React.useContext(NavContext); const navigationState = navContext && navContext.state; const theme = useSelector( (state: AppState) => state.globalThemeInfo.activeTheme, ); const backgroundIsDark = React.useMemo( () => selectBackgroundIsDark(navigationState, theme), [navigationState, theme], ); const syntheticTheme = backgroundIsDark ? 'dark' : 'light'; return React.useMemo( () => stylesFromColors(obj, colors[syntheticTheme]), [obj, syntheticTheme], ); } function useColors(): Colors { return useSelector(colorsSelector); } function getStylesForTheme( obj: IS, theme: GlobalTheme, ): StyleSheetOf { return stylesFromColors(obj, colors[theme]); } export type IndicatorStyle = 'white' | 'black'; function useIndicatorStyle(): IndicatorStyle { const theme = useSelector( (state: AppState) => state.globalThemeInfo.activeTheme, ); return theme && theme === 'dark' ? 'white' : 'black'; } const indicatorStyleSelector: (state: AppState) => IndicatorStyle = createSelector( (state: AppState) => state.globalThemeInfo.activeTheme, (theme: ?GlobalTheme) => { return theme && theme === 'dark' ? 'white' : 'black'; }, ); export type KeyboardAppearance = 'default' | 'light' | 'dark'; const keyboardAppearanceSelector: (state: AppState) => KeyboardAppearance = createSelector( (state: AppState) => state.globalThemeInfo.activeTheme, (theme: ?GlobalTheme) => { return theme && theme === 'dark' ? 'dark' : 'light'; }, ); function useKeyboardAppearance(): KeyboardAppearance { return useSelector(keyboardAppearanceSelector); } export { colors, colorsSelector, styleSelector, useStyles, useOverlayStyles, useColors, getStylesForTheme, useIndicatorStyle, indicatorStyleSelector, useKeyboardAppearance, }; diff --git a/native/themes/theme-handler.react.js b/native/themes/theme-handler.react.js index e66b6ebc3..0792e4777 100644 --- a/native/themes/theme-handler.react.js +++ b/native/themes/theme-handler.react.js @@ -1,68 +1,65 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { Appearance } from 'react-native'; import { useDispatch } from 'react-redux'; import type { Shape } from 'lib/types/core.js'; +import type { GlobalTheme, GlobalThemeInfo } from 'lib/types/theme-types.js'; import { updateThemeInfoActionType } from '../redux/action-types.js'; import { useSelector } from '../redux/redux-utils.js'; -import { - type GlobalTheme, - type GlobalThemeInfo, - osCanTheme, -} from '../types/themes.js'; +import { osCanTheme } from '../types/themes.js'; function ThemeHandler(): null { const globalThemeInfo = useSelector(state => state.globalThemeInfo); const dispatch = useDispatch(); const updateSystemTheme = React.useCallback( (colorScheme: ?GlobalTheme) => { if (globalThemeInfo.systemTheme === colorScheme) { return; } let updateObject: Shape = { systemTheme: colorScheme, }; if (globalThemeInfo.preference === 'system') { updateObject = { ...updateObject, activeTheme: colorScheme }; } dispatch({ type: updateThemeInfoActionType, payload: updateObject, }); }, [globalThemeInfo, dispatch], ); React.useEffect(() => { if (!osCanTheme) { return undefined; } const subscription = Appearance.addChangeListener(({ colorScheme }) => { invariant( colorScheme === undefined || colorScheme === null || colorScheme === 'light' || colorScheme === 'dark', 'Flow types for Appearance module are non-specific', ); updateSystemTheme(colorScheme); }); return () => subscription.remove(); }, [updateSystemTheme]); React.useEffect( () => updateSystemTheme(Appearance.getColorScheme()), // eslint-disable-next-line react-hooks/exhaustive-deps [], ); return null; } export default ThemeHandler; diff --git a/native/types/themes.js b/native/types/themes.js index 6b956926a..2347af017 100644 --- a/native/types/themes.js +++ b/native/types/themes.js @@ -1,25 +1,7 @@ // @flow import { Platform } from 'react-native'; -export type GlobalTheme = 'light' | 'dark'; -export type GlobalThemePreference = GlobalTheme | 'system'; - -export type GlobalThemeInfo = { - +activeTheme: ?GlobalTheme, - +systemTheme: ?GlobalTheme, - +preference: GlobalThemePreference, -}; - export const osCanTheme: boolean = (Platform.OS === 'ios' && parseInt(Platform.Version, 10) >= 13) || (Platform.OS === 'android' && Platform.Version >= 29); - -export const defaultGlobalThemeInfo = { - // revert to `activeTheme: osCanTheme ? null : 'light'` to re-enable theming - activeTheme: 'dark', - systemTheme: null, - // revert to `preference: osCanTheme ? 'system' : 'light'` - // to re-enable theming - preference: 'dark', -}; diff --git a/native/utils/alert.js b/native/utils/alert.js index abd2be41c..e2fedf4ee 100644 --- a/native/utils/alert.js +++ b/native/utils/alert.js @@ -1,48 +1,52 @@ // @flow import { Alert } from 'react-native'; +import { + defaultGlobalThemeInfo, + type GlobalTheme, +} from 'lib/types/theme-types.js'; + import { store } from '../redux/redux-setup.js'; -import { type GlobalTheme, defaultGlobalThemeInfo } from '../types/themes.js'; type AlertWrapper = { +alert: typeof Alert.alert, +prompt: typeof Alert.prompt, }; const getCurrentTheme = (): GlobalTheme => store.getState().globalThemeInfo.activeTheme || defaultGlobalThemeInfo.activeTheme; const alertWrapper: AlertWrapper = { alert(title, message, buttons, options) { Alert.alert(title, message, buttons, { userInterfaceStyle: getCurrentTheme(), ...options, }); }, prompt( title, message, callbackOrButtons, type, defaultValue, keyboardType, options, ) { Alert.prompt( title, message, callbackOrButtons, type, defaultValue, keyboardType, { userInterfaceStyle: getCurrentTheme(), ...options, }, ); }, }; export default alertWrapper;