Skip to content

Prepare for "Auto - follow OS" option for Theme setting #5527

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Nov 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 13 additions & 14 deletions docs/architecture/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,20 +141,19 @@ doc](https://reactjs.org/docs/context.html)):
> [...])
> is not subject to the shouldComponentUpdate method

We also confirmed this behavior experimentally, in a 2020 version of
`MessageList` which used `ThemeContext` to get the theme colors.
(Since 1ba871910, `MessageList` uses a transparent background and so
doesn't need the theme; since 4fa2418b8 it doesn't mention
`ThemeContext`.) That component re-`render`ed when the theme changed,
*even though its `shouldComponentUpdate` always returned `false`*.
This didn't cause a live problem because the UI doesn't
allow changing the theme while a `MessageList` is in the navigation
stack. If it were possible, it would be a concern: setting a short
interval to automatically toggle the theme, we see that the message
list's color scheme changes as we'd want it to, but we also see the
bad effects that `shouldComponentUpdate` returning `false` is meant to
prevent: losing the scroll position, mainly (but also, we expect,
discarding the image cache, etc.).
Concretely, this means that our `MessageList` component updates
(re-`render`s) when the theme changes, since it's a `ThemeContext`
consumer, *even though its `shouldComponentUpdate` always returns
`false`*. This generally isn't a problem because the UI for
changing our own theme setting can't appear while a `MessageList` is
in the navigation stack; so the theme can change only once we have
#4009, via the OS-level theme changing (either automatically on
schedule, or because the user changed it in system settings.) When
this does happen we see that the message list's color scheme changes
as we'd want it to, but we also see the bad effects that
`shouldComponentUpdate` returning `false` is meant to prevent:
losing the scroll position, mainly (but also, we expect, discarding
the image cache, etc.).

### The exception: `MessageList`

Expand Down
23 changes: 18 additions & 5 deletions src/boot/OfflineNoticeProvider.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
// @flow strict-local
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import type { Node } from 'react';
import { AccessibilityInfo, View, Animated, LayoutAnimation, Platform, Easing } from 'react-native';
import {
AccessibilityInfo,
View,
Animated,
LayoutAnimation,
Platform,
Easing,
useColorScheme,
} from 'react-native';
import NetInfo from '@react-native-community/netinfo';
import { SafeAreaView } from 'react-native-safe-area-context';
import type { ViewProps } from 'react-native/Libraries/Components/View/ViewPropTypes';
Expand All @@ -10,6 +18,7 @@ import type { DimensionValue } from 'react-native/Libraries/StyleSheet/StyleShee
import * as logging from '../utils/logging';
import { useDispatch, useGlobalSelector } from '../react-redux';
import { getGlobalSession, getGlobalSettings } from '../directSelectors';
import { getThemeToUse } from '../settings/settingsSelectors';
import { useHasStayedTrueForMs, usePrevious } from '../reactUtils';
import type { JSONableDict } from '../utils/jsonable';
import { createStyleSheet } from '../styles';
Expand Down Expand Up @@ -131,7 +140,7 @@ const backgroundColorForTheme = (theme: ThemeName): string =>
// TODO(redesign): Choose these more intentionally; these are just the
// semitransparent HALF_COLOR flattened with themeData.backgroundColor.
// See https://github.com/zulip/zulip-mobile/pull/5491#issuecomment-1282859332
theme === 'default' ? '#bfbfbf' : '#50565e';
theme === 'light' ? '#bfbfbf' : '#50565e';

/**
* Shows a notice if the app is working in offline mode.
Expand All @@ -148,6 +157,8 @@ const backgroundColorForTheme = (theme: ThemeName): string =>
*/
export function OfflineNoticeProvider(props: ProviderProps): Node {
const theme = useGlobalSelector(state => getGlobalSettings(state).theme);
const osScheme = useColorScheme();
const themeToUse = getThemeToUse(theme, osScheme);
const _ = useContext(TranslationContext);
const isOnline = useGlobalSelector(state => getGlobalSession(state).isOnline);
const shouldShowUncertaintyNotice = useShouldShowUncertaintyNotice();
Expand Down Expand Up @@ -249,7 +260,7 @@ export function OfflineNoticeProvider(props: ProviderProps): Node {

// If changing, also change the status bar color in
// OfflineNoticePlaceholder.
backgroundColor: backgroundColorForTheme(theme),
backgroundColor: backgroundColorForTheme(themeToUse),

justifyContent: 'center',
alignItems: 'center',
Expand All @@ -262,7 +273,7 @@ export function OfflineNoticeProvider(props: ProviderProps): Node {
},
noticeText: { fontSize: 14 },
}),
[isNoticeVisible, theme],
[isNoticeVisible, themeToUse],
);

/**
Expand Down Expand Up @@ -382,6 +393,8 @@ type PlaceholderProps = $ReadOnly<{|
*/
export function OfflineNoticePlaceholder(props: PlaceholderProps): Node {
const theme = useGlobalSelector(state => getGlobalSettings(state).theme);
const osScheme = useColorScheme();
const themeToUse = getThemeToUse(theme, osScheme);
const { style: callerStyle } = props;

const { isNoticeVisible, noticeContentAreaHeight } = useContext(OfflineNoticeContext);
Expand Down Expand Up @@ -451,7 +464,7 @@ export function OfflineNoticePlaceholder(props: PlaceholderProps): Node {
isNoticeVisible && (
<ZulipStatusBar
// Should match the notice's surface; see OfflineNoticeProvider.
backgroundColor={backgroundColorForTheme(theme)}
backgroundColor={backgroundColorForTheme(themeToUse)}
/>
)
}
Expand Down
7 changes: 6 additions & 1 deletion src/boot/ThemeProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

import React from 'react';
import type { Node } from 'react';
import { useColorScheme } from 'react-native';

import { useGlobalSelector } from '../react-redux';
import { getGlobalSettings } from '../directSelectors';
import { themeData, ThemeContext } from '../styles/theme';
import ZulipStatusBar from '../common/ZulipStatusBar';
import { getThemeToUse } from '../settings/settingsSelectors';

type Props = $ReadOnly<{|
children: Node,
Expand All @@ -15,8 +17,11 @@ type Props = $ReadOnly<{|
export default function ThemeProvider(props: Props): Node {
const { children } = props;
const theme = useGlobalSelector(state => getGlobalSettings(state).theme);
const osScheme = useColorScheme();
const themeToUse = getThemeToUse(theme, osScheme);

return (
<ThemeContext.Provider value={themeData[theme]}>
<ThemeContext.Provider value={themeData[themeToUse]}>
<ZulipStatusBar />
{children}
</ThemeContext.Provider>
Expand Down
8 changes: 7 additions & 1 deletion src/boot/ZulipSafeAreaProvider.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
// @flow strict-local
import * as React from 'react';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { useColorScheme } from 'react-native';

import { BRAND_COLOR } from '../styles';
import { useGlobalSelector } from '../react-redux';
import { getGlobalSettings, getIsHydrated } from '../directSelectors';
import { themeData } from '../styles/theme';
import { getThemeToUse } from '../settings/settingsSelectors';

type Props = {|
+children: React.Node,
Expand All @@ -27,14 +29,18 @@ export default function ZulipSafeAreaProvider(props: Props): React.Node {
//
// We can make this quirk virtually invisible by giving it the background
// color used across the app.
const osScheme = useColorScheme();

const backgroundColor = useGlobalSelector(state => {
if (!getIsHydrated(state)) {
// The only screen we'll be showing at this point is the loading
// screen. Match that screen's background.
return BRAND_COLOR;
}

return themeData[getGlobalSettings(state).theme].backgroundColor;
const theme = getGlobalSettings(state).theme;
const themeToUse = getThemeToUse(theme, osScheme);
return themeData[themeToUse].backgroundColor;
});

return <SafeAreaProvider style={{ backgroundColor }}>{props.children}</SafeAreaProvider>;
Expand Down
9 changes: 7 additions & 2 deletions src/common/Popup.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
/* @flow strict-local */
import React, { useContext } from 'react';
import type { Node } from 'react';
import { View } from 'react-native';
import { View, useColorScheme } from 'react-native';

import { ThemeContext, createStyleSheet } from '../styles';
import { useGlobalSelector } from '../react-redux';
import { getGlobalSettings } from '../directSelectors';
import { getThemeToUse } from '../settings/settingsSelectors';

const styles = createStyleSheet({
popup: {
Expand Down Expand Up @@ -45,8 +46,12 @@ type Props = $ReadOnly<{|
*/
export default function Popup(props: Props): Node {
const themeContext = useContext(ThemeContext);
const theme = useGlobalSelector(state => getGlobalSettings(state).theme);
const osScheme = useColorScheme();
const themeToUse = getThemeToUse(theme, osScheme);

// TODO(color/theme): find a cleaner way to express this
const isDarkTheme = useGlobalSelector(state => getGlobalSettings(state).theme !== 'default');
const isDarkTheme = themeToUse !== 'light';
Comment on lines 53 to +54
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see, in several of these components it won't suffice to say useContext(ThemeProvider) because the latter only gives the ThemeData (with the concrete colors) and not the identity of the theme as dark or light.

As the existing comment here says, it'd be good to clean that up at some point. But for purposes of this PR, I'll be happy to let that be.

Still, let's avoid duplicating all those conditionals involved in computing themeToUse. These components can each call a central function, like the getThemeToUse described above, and let that take care of that logic.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

return (
<View style={[{ backgroundColor: themeContext.backgroundColor }, styles.popup]}>
<View style={isDarkTheme && styles.overlay}>{props.children}</View>
Expand Down
11 changes: 8 additions & 3 deletions src/common/ZulipStatusBar.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,20 @@

import React from 'react';
import type { Node } from 'react';
import { Platform, StatusBar } from 'react-native';
import { Platform, StatusBar, useColorScheme } from 'react-native';
// $FlowFixMe[untyped-import]
import Color from 'color';

import type { ThemeName } from '../types';
import { useGlobalSelector } from '../react-redux';
import { foregroundColorFromBackground } from '../utils/color';
import { getGlobalSession, getGlobalSettings } from '../selectors';
import { getThemeToUse } from '../settings/settingsSelectors';

type BarStyle = React$ElementConfig<typeof StatusBar>['barStyle'];

export const getStatusBarColor = (backgroundColor: string | void, theme: ThemeName): string =>
backgroundColor ?? (theme === 'night' ? 'hsl(212, 28%, 18%)' : 'white');
backgroundColor ?? (theme === 'dark' ? 'hsl(212, 28%, 18%)' : 'white');

export const getStatusBarStyle = (statusBarColor: string): BarStyle =>
foregroundColorFromBackground(statusBarColor) === 'white' /* force newline */
Expand Down Expand Up @@ -46,9 +47,13 @@ type Props = $ReadOnly<{|
export default function ZulipStatusBar(props: Props): Node {
const { hidden = false } = props;
const theme = useGlobalSelector(state => getGlobalSettings(state).theme);
const osScheme = useColorScheme();
const themeToUse = getThemeToUse(theme, osScheme);

const orientation = useGlobalSelector(state => getGlobalSession(state).orientation);
const backgroundColor = props.backgroundColor;
const statusBarColor = getStatusBarColor(backgroundColor, theme);
const statusBarColor = getStatusBarColor(backgroundColor, themeToUse);

return (
orientation === 'PORTRAIT' && (
<StatusBar
Expand Down
12 changes: 6 additions & 6 deletions src/common/__tests__/getStatusBarColor-test.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
/* @flow strict-local */
import { getStatusBarColor } from '../ZulipStatusBar';

const themeNight = 'night';
const themeDefault = 'default';
const themeDark = 'dark';
const themeLight = 'light';

describe('getStatusBarColor', () => {
test('returns specific color when given, regardless of theme', () => {
expect(getStatusBarColor('#fff', themeDefault)).toEqual('#fff');
expect(getStatusBarColor('#fff', themeNight)).toEqual('#fff');
expect(getStatusBarColor('#fff', themeLight)).toEqual('#fff');
expect(getStatusBarColor('#fff', themeDark)).toEqual('#fff');
});

test('returns color according to theme for default case', () => {
expect(getStatusBarColor(undefined, themeDefault)).toEqual('white');
expect(getStatusBarColor(undefined, themeNight)).toEqual('hsl(212, 28%, 18%)');
expect(getStatusBarColor(undefined, themeLight)).toEqual('white');
expect(getStatusBarColor(undefined, themeDark)).toEqual('hsl(212, 28%, 18%)');
});
});
8 changes: 6 additions & 2 deletions src/nav/ZulipNavigationContainer.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/* @flow strict-local */
import React, { useContext, useEffect } from 'react';
import type { Node } from 'react';
import { useColorScheme } from 'react-native';
import { NavigationContainer, DefaultTheme, DarkTheme } from '@react-navigation/native';

import { useGlobalSelector } from '../react-redux';
import { ThemeContext } from '../styles';
import * as NavigationService from './NavigationService';
import { getGlobalSettings } from '../selectors';
import AppNavigator from './AppNavigator';
import { getThemeToUse } from '../settings/settingsSelectors';

type Props = $ReadOnly<{||}>;

Expand All @@ -23,6 +25,8 @@ type Props = $ReadOnly<{||}>;
*/
export default function ZulipAppContainer(props: Props): Node {
const themeName = useGlobalSelector(state => getGlobalSettings(state).theme);
const osScheme = useColorScheme();
const themeToUse = getThemeToUse(themeName, osScheme);

useEffect(
() =>
Expand All @@ -36,11 +40,11 @@ export default function ZulipAppContainer(props: Props): Node {

const themeContext = useContext(ThemeContext);

const BaseTheme = themeName === 'night' ? DarkTheme : DefaultTheme;
const BaseTheme = themeToUse === 'dark' ? DarkTheme : DefaultTheme;

const theme = {
...BaseTheme,
dark: themeName === 'night',
dark: themeToUse === 'dark',
colors: {
...BaseTheme.colors,
primary: themeContext.color,
Expand Down
24 changes: 19 additions & 5 deletions src/reduxTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -348,10 +348,24 @@ export type RealmState = {|
+serverEmojiData: ServerEmojiData | null,
|};

// TODO: Stop using the 'default' name. Any 'default' semantics should
// only apply the device level, not within the app. See
// https://github.com/zulip/zulip-mobile/issues/4009#issuecomment-619280681.
export type ThemeName = 'default' | 'night';
/**
* The visual theme of the app.
*
* To get a ThemeName from the ThemeSetting, first check the current
* OS theme by calling the React Native hook useColorScheme and pass
* that to the helper function getThemeToUse.
Comment on lines +354 to +356
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, this is helpful information to include in jsdoc.

*/
export type ThemeName = 'light' | 'dark';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commit-message nit:

theme [nfc]: rename internally from default/night to light/dark

The summary line should be capitalized after the colon-prefix, so like:

theme [nfc]: Rename internally from default/night to light/dark

(I think the branch I provided for splitting the changes may have been confusing on this, because I had summary lines like wip theme [nfc]: rename internally to light/dark. But the lack of capitalization was one of the things making the commits WIP.)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I think I had this correct before but missed this detail after your slice and dice help.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also in this commit message, a bit more substantive:

theme [nfc]: rename internally from default/night to light/dark

As mentioned in issue #4009, this changes how the theme of the app
is described. It changes all instances of 'default' to 'light' and
'night' to 'dark'.

Fixes #5169.

This commit doesn't fix #5169, because that's about what the user sees and this commit is internal to the code. The next commit does, though, so the "fixes" line should just go there.

Also on the "fixes" line, a formatting nit:
https://github.com/zulip/zulip-mobile/blob/main/docs/style.md#fixes-format


/**
* The theme setting.
*
* This represents the value the user chooses in SettingsScreen.
*
* To determine the actual theme to show the user, use a ThemeName;
* see there for details.
*/
export type ThemeSetting = 'default' | 'night';

/** What browser the user has set to use for opening links in messages.
*
Expand Down Expand Up @@ -392,7 +406,7 @@ export type GlobalSettingsState = $ReadOnly<{
// The user's chosen language, as an IETF BCP 47 language tag.
language: string,

theme: ThemeName,
theme: ThemeSetting,
browser: BrowserPreference,

// TODO cut this? what was it?
Expand Down
6 changes: 6 additions & 0 deletions src/settings/settingsSelectors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/* @flow strict-local */
import type { ColorSchemeName } from 'react-native/Libraries/Utilities/NativeAppearance';
import type { ThemeSetting, ThemeName } from '../reduxTypes';

export const getThemeToUse = (theme: ThemeSetting, osScheme: ?ColorSchemeName): ThemeName =>
theme === 'default' ? 'light' : 'dark';
14 changes: 7 additions & 7 deletions src/start/IosCompliantAppleAuthButton/Custom.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,21 @@ const styles = createStyleSheet({
borderRadius: 22,
overflow: 'hidden',
},
nightFrame: {
darkFrame: {
backgroundColor: 'black',
},
dayFrame: {
lightFrame: {
backgroundColor: 'white',
borderWidth: 1.5,
borderColor: 'black',
},
text: {
fontSize: 16,
},
nightText: {
darkText: {
color: 'white',
},
dayText: {
lightText: {
color: 'black',
},
});
Expand All @@ -57,13 +57,13 @@ type Props = $ReadOnly<{|
*/
export default function Custom(props: Props): Node {
const { style, onPress, theme } = props;
const logoSource = theme === 'default' ? appleLogoBlackImg : appleLogoWhiteImg;
const logoSource = theme === 'light' ? appleLogoBlackImg : appleLogoWhiteImg;
const frameStyle = [
styles.frame,
theme === 'default' ? styles.dayFrame : styles.nightFrame,
theme === 'light' ? styles.lightFrame : styles.darkFrame,
style,
];
const textStyle = [styles.text, theme === 'default' ? styles.dayText : styles.nightText];
const textStyle = [styles.text, theme === 'light' ? styles.lightText : styles.darkText];

return (
<View style={frameStyle}>
Expand Down
Loading