diff --git a/lib/types/theme-types.js b/lib/types/theme-types.js new file mode 100644 index 000000000..37c461532 --- /dev/null +++ b/lib/types/theme-types.js @@ -0,0 +1,10 @@ +// @flow + +export type GlobalTheme = 'light' | 'dark'; +export type GlobalThemePreference = GlobalTheme | 'system'; + +export type GlobalThemeInfo = { + +activeTheme: ?GlobalTheme, + +systemTheme: ?GlobalTheme, + +preference: GlobalThemePreference, +}; 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..fa3b487fb 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..f23a93765 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 { + GlobalThemePreference, + GlobalThemeInfo, +} 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/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..927d333da 100644 --- a/native/redux/theme-reducer.js +++ b/native/redux/theme-reducer.js @@ -1,47 +1,45 @@ // @flow import { siweAuthActionTypes } from 'lib/actions/siwe-actions.js'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, } from 'lib/actions/user-actions.js'; +import 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'; +import { 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..e4b25afe5 100644 --- a/native/types/themes.js +++ b/native/types/themes.js @@ -1,25 +1,16 @@ // @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..805dddfc6 100644 --- a/native/utils/alert.js +++ b/native/utils/alert.js @@ -1,48 +1,50 @@ // @flow import { Alert } from 'react-native'; +import type { GlobalTheme } from 'lib/types/theme-types.js'; + import { store } from '../redux/redux-setup.js'; -import { type GlobalTheme, defaultGlobalThemeInfo } from '../types/themes.js'; +import { 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;