diff --git a/assets/images/moon.svg b/assets/images/moon.svg new file mode 100644 index 000000000000..edd130c0a25b --- /dev/null +++ b/assets/images/moon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/CONST/index.ts b/src/CONST/index.ts index c4b8f0f4677b..c5f67959a48a 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8433,6 +8433,9 @@ const CONST = { BILLING_BANNER: { RIGHT_ICON: 'BillingBanner-RightIcon', }, + HIGH_CONTRAST_MODE_SWITCHER: { + TOGGLE: 'HighContrastModeSwitcher-Toggle', + }, AGENTS_WORKFLOWS_BANNER: { DISMISS: 'AgentsWorkflowsBanner-Dismiss', }, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 451db5291bb9..c91789c28b6f 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -448,6 +448,10 @@ const ONYXKEYS = { // This can be either "light", "dark", "system", "light-contrast", "dark-contrast" or "system-contrast" PREFERRED_THEME: 'nvp_preferredTheme', + // Client-only flag set when a logged-out user enables high contrast on the sign-in page. + // It is reconciled with the server's base theme right after sign-in, then cleared. + SIGN_IN_HIGH_CONTRAST_INTENT: 'signInHighContrastIntent', + // Information about the onyx updates IDs that were received from the server ONYX_UPDATES_FROM_SERVER: 'onyxUpdatesFromServer', @@ -1553,6 +1557,7 @@ type OnyxValuesMapping = { [ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[]; [ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string; [ONYXKEYS.PREFERRED_THEME]: ValueOf; + [ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT]: boolean; [ONYXKEYS.MAPBOX_ACCESS_TOKEN]: OnyxTypes.MapboxAccessToken; [ONYXKEYS.ONYX_UPDATES_FROM_SERVER]: OnyxTypes.AnyOnyxUpdatesFromServer; [ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT]: number; diff --git a/src/components/HighContrastModeSwitcher.tsx b/src/components/HighContrastModeSwitcher.tsx new file mode 100644 index 000000000000..22332e1b220e --- /dev/null +++ b/src/components/HighContrastModeSwitcher.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getBaseTheme, getContrastTheme, isHighContrastTheme} from '@styles/theme/utils'; +import variables from '@styles/variables'; +import {setHighContrastIntent, updateTheme} from '@userActions/User'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import Icon from './Icon'; +import PressableWithFeedback from './Pressable/PressableWithFeedback'; +import Text from './Text'; + +function HighContrastModeSwitcher() { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [preferredTheme] = useOnyx(ONYXKEYS.PREFERRED_THEME); + const icons = useMemoizedLazyExpensifyIcons(['Moon']); + + const currentTheme = preferredTheme ?? CONST.THEME.DEFAULT; + const isHighContrast = isHighContrastTheme(currentTheme); + const label = translate(isHighContrast ? 'themePage.disableHighContrast' : 'themePage.enableHighContrast'); + + const toggleHighContrast = () => { + const baseTheme = getBaseTheme(currentTheme); + updateTheme(isHighContrast ? baseTheme : getContrastTheme(baseTheme), false); + setHighContrastIntent(!isHighContrast); + }; + + return ( + + + {label} + + ); +} + +export default HighContrastModeSwitcher; diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index 821fb37ef966..758f440b4962 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -175,6 +175,7 @@ import MoneyHourglass from '@assets/images/money-hourglass.svg'; import MoneySearch from '@assets/images/money-search.svg'; import MoneyWaving from '@assets/images/money-waving.svg'; import Monitor from '@assets/images/monitor.svg'; +import Moon from '@assets/images/moon.svg'; import MultiTag from '@assets/images/multi-tag.svg'; import Fingerprint from '@assets/images/multifactorAuthentication/fingerprint.svg'; import Mute from '@assets/images/mute.svg'; @@ -416,6 +417,7 @@ const Expensicons = { MoneyWaving, MoneyHourglass, Monitor, + Moon, Mute, ExpensifyLogoNew, NewWindow, diff --git a/src/hooks/useReconcileHighContrastIntent.ts b/src/hooks/useReconcileHighContrastIntent.ts new file mode 100644 index 000000000000..60730b2a7b91 --- /dev/null +++ b/src/hooks/useReconcileHighContrastIntent.ts @@ -0,0 +1,35 @@ +import {useEffect, useRef} from 'react'; +import {getBaseTheme, getContrastTheme} from '@styles/theme/utils'; +import {setHighContrastIntent, updateTheme} from '@userActions/User'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useOnyx from './useOnyx'; + +/** + * Reconcile a logged-out user's high contrast choice from the sign-in page with the base theme the server returns once they sign in. + * The intent is `true` when they enabled it, `false` when they disabled it, and cleared when there is nothing to reconcile. + */ +function useReconcileHighContrastIntent() { + const [preferredTheme] = useOnyx(ONYXKEYS.PREFERRED_THEME); + const [highContrastIntent] = useOnyx(ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT); + const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); + const [session] = useOnyx(ONYXKEYS.SESSION); + const wasLoadingApp = useRef(undefined); + + useEffect(() => { + const hasFinishedLoading = !!wasLoadingApp.current && !isLoadingApp; + wasLoadingApp.current = isLoadingApp; + if (!hasFinishedLoading || highContrastIntent === undefined || !session?.authToken) { + return; + } + const currentTheme = preferredTheme ?? CONST.THEME.DEFAULT; + const baseTheme = getBaseTheme(currentTheme); + const targetTheme = highContrastIntent ? getContrastTheme(baseTheme) : baseTheme; + if (currentTheme !== targetTheme) { + updateTheme(targetTheme, false); + } + setHighContrastIntent(null); + }, [isLoadingApp, highContrastIntent, preferredTheme, session?.authToken]); +} + +export default useReconcileHighContrastIntent; diff --git a/src/languages/de.ts b/src/languages/de.ts index 4aab5fbe808d..60a3816ec883 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2930,6 +2930,8 @@ ${amount} für ${merchant} – ${date}`, }, }, highContrastMode: 'Hoher Kontrast', + enableHighContrast: 'Hohen Kontrast aktivieren', + disableHighContrast: 'Hohen Kontrast deaktivieren', chooseThemeBelowOrSync: 'Wählen Sie unten ein Design aus oder synchronisieren Sie es mit den Einstellungen Ihres Geräts.', }, termsOfUse: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 156c2c6b3695..4bc8faebe702 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3007,6 +3007,8 @@ const translations = { }, }, highContrastMode: 'High contrast mode', + enableHighContrast: 'Enable high contrast', + disableHighContrast: 'Disable high contrast', chooseThemeBelowOrSync: 'Choose a theme below, or sync with your device settings.', }, termsOfUse: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 12fc795dbcd6..adf88b2e46b5 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2805,6 +2805,8 @@ ${amount} para ${merchant} - ${date}`, }, }, highContrastMode: 'Modo de alto contraste', + enableHighContrast: 'Activar alto contraste', + disableHighContrast: 'Desactivar alto contraste', chooseThemeBelowOrSync: 'Elige un tema a continuación o sincronízalo con los ajustes de tu dispositivo.', }, termsOfUse: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index e1924676e2c2..cdc80c78b951 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2938,6 +2938,8 @@ ${amount} pour ${merchant} - ${date}`, }, }, highContrastMode: 'Mode contraste élevé', + enableHighContrast: 'Activer le contraste élevé', + disableHighContrast: 'Désactiver le contraste élevé', chooseThemeBelowOrSync: 'Choisissez un thème ci-dessous ou synchronisez avec les réglages de votre appareil.', }, termsOfUse: { diff --git a/src/languages/it.ts b/src/languages/it.ts index b74810e139e7..b85253bb995d 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2926,6 +2926,8 @@ ${amount} per ${merchant} - ${date}`, }, }, highContrastMode: 'Modalità alto contrasto', + enableHighContrast: 'Attiva alto contrasto', + disableHighContrast: 'Disattiva alto contrasto', chooseThemeBelowOrSync: 'Scegli un tema qui sotto o sincronizza con le impostazioni del tuo dispositivo.', }, termsOfUse: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index b001b3c7cfd5..59c6194857ef 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2898,6 +2898,8 @@ ${date} の ${merchant} への ${amount}`, }, }, highContrastMode: 'ハイコントラストモード', + enableHighContrast: 'ハイコントラストを有効にする', + disableHighContrast: 'ハイコントラストを無効にする', chooseThemeBelowOrSync: '以下からテーマを選択するか、デバイスの設定と同期してください。', }, termsOfUse: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ddd96d1d7e65..5b3014ffdcc5 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2923,6 +2923,8 @@ ${amount} voor ${merchant} - ${date}`, }, }, highContrastMode: 'Hoog contrast', + enableHighContrast: 'Hoog contrast inschakelen', + disableHighContrast: 'Hoog contrast uitschakelen', chooseThemeBelowOrSync: 'Kies hieronder een thema, of synchroniseer met de instellingen van je apparaat.', }, termsOfUse: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 4d73d8b074ee..12036c0a3c15 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2917,6 +2917,8 @@ ${amount} dla ${merchant} - ${date}`, }, }, highContrastMode: 'Tryb wysokiego kontrastu', + enableHighContrast: 'Włącz wysoki kontrast', + disableHighContrast: 'Wyłącz wysoki kontrast', chooseThemeBelowOrSync: 'Wybierz motyw poniżej lub zsynchronizuj z ustawieniami urządzenia.', }, termsOfUse: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index b1b7653c6e71..3585b9bffc20 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2917,6 +2917,8 @@ ${amount} para ${merchant} - ${date}`, }, }, highContrastMode: 'Modo de alto contraste', + enableHighContrast: 'Ativar alto contraste', + disableHighContrast: 'Desativar alto contraste', chooseThemeBelowOrSync: 'Escolha um tema abaixo ou sincronize com as configurações do seu dispositivo.', }, termsOfUse: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 4d017ea5a363..02c329822c50 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2841,6 +2841,8 @@ ${amount},商户:${merchant} - 日期:${date}`, }, }, highContrastMode: '高对比度模式', + enableHighContrast: '启用高对比度', + disableHighContrast: '关闭高对比度', chooseThemeBelowOrSync: '请选择下方的主题,或与您的设备设置同步。', }, termsOfUse: { diff --git a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx index ab7d2acd6208..f953488083a9 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx +++ b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx @@ -7,6 +7,7 @@ import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies'; import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useReconcileHighContrastIntent from '@hooks/useReconcileHighContrastIntent'; import useReportAttributes from '@hooks/useReportAttributes'; import {init, isClientTheLeader} from '@libs/ActiveClientManager'; import Log from '@libs/Log'; @@ -73,6 +74,8 @@ function AuthScreensInitHandler() { const reportAttributesRef = useRef(reportAttributes); reportAttributesRef.current = reportAttributes; + useReconcileHighContrastIntent(); + useEffect(() => { if (!Navigation.isActiveRoute(ROUTES.SIGN_IN_MODAL)) { return; diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index a14e9aecdbcd..2e7785835e71 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -135,6 +135,7 @@ const KEYS_TO_PRESERVE: OnyxKey[] = [ ONYXKEYS.SESSION, ONYXKEYS.NVP_TRY_FOCUS_MODE, ONYXKEYS.PREFERRED_THEME, + ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT, ONYXKEYS.NVP_PREFERRED_LOCALE, ONYXKEYS.CREDENTIALS, ONYXKEYS.PRESERVED_USER_SESSION, diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index faae7abbf801..42d1aadf8c1c 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -65,9 +65,18 @@ import applyOnyxUpdatesReliably from './applyOnyxUpdatesReliably'; import {getDeviceInfoWithID} from './Device'; import {openOldDotLink} from './Link'; import {showReportActionNotification} from './Report'; -import {resendValidateCode as sessionResendValidateCode} from './Session'; +import {isAnonymousUser, resendValidateCode as sessionResendValidateCode} from './Session'; import redirectToSignIn from './SignInRedirect'; +// `sessionAccountID` is only used in actions, not during render. So `Onyx.connectWithoutView` is appropriate. +let sessionAccountID: number | undefined; +Onyx.connectWithoutView({ + key: ONYXKEYS.SESSION, + callback: (value) => { + sessionAccountID = value?.accountID; + }, +}); + type DomainOnyxUpdate = | OnyxUpdate<`${typeof ONYXKEYS.COLLECTION.DOMAIN}${string}`> | OnyxUpdate<`${typeof ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${string}`> @@ -1214,6 +1223,12 @@ function setContactMethodAsDefault( } function updateTheme(theme: ValueOf, shouldGoBack = true) { + // When toggling high contrast from the sign-in page, the user is not signed in. So persist the preference locally only. + if (!sessionAccountID || isAnonymousUser()) { + Onyx.set(ONYXKEYS.PREFERRED_THEME, theme); + return; + } + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.SET, @@ -1233,6 +1248,10 @@ function updateTheme(theme: ValueOf, shouldGoBack = true) { } } +function setHighContrastIntent(hasIntent: boolean | null) { + Onyx.set(ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT, hasIntent); +} + /** * Sets a custom status */ @@ -1914,6 +1933,7 @@ export { updateChatPriorityMode, setContactMethodAsDefault, updateTheme, + setHighContrastIntent, resetContactMethodValidateCodeSentState, updateCustomStatus, clearCustomStatus, diff --git a/src/pages/signin/Licenses.tsx b/src/pages/signin/Licenses.tsx index 5a6227b3a966..cc1c92b3a95b 100644 --- a/src/pages/signin/Licenses.tsx +++ b/src/pages/signin/Licenses.tsx @@ -1,5 +1,6 @@ import React from 'react'; import {View} from 'react-native'; +import HighContrastModeSwitcher from '@components/HighContrastModeSwitcher'; import LocalePicker from '@components/LocalePicker'; import RenderHTML from '@components/RenderHTML'; import Text from '@components/Text'; @@ -18,6 +19,9 @@ function Licenses() { ${translate('termsOfUse.license')}`} /> + + + diff --git a/src/pages/signin/SignInPage.tsx b/src/pages/signin/SignInPage.tsx index 9409ed2c94a7..7ddf5b6ce508 100644 --- a/src/pages/signin/SignInPage.tsx +++ b/src/pages/signin/SignInPage.tsx @@ -360,8 +360,10 @@ function SignInPageWrapper({ref}: SignInPageProps) { function WithTheme(Component: React.ComponentType) { function ThemedComponent({ref}: SignInPageProps) { const [preferredTheme] = useOnyx(ONYXKEYS.PREFERRED_THEME); + const [highContrastIntent] = useOnyx(ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT); const contrastThemes: string[] = [CONST.THEME.DARK_CONTRAST, CONST.THEME.LIGHT_CONTRAST, CONST.THEME.SYSTEM_CONTRAST]; - const signInTheme = contrastThemes.includes(preferredTheme ?? '') ? CONST.THEME.DARK_CONTRAST : CONST.THEME.DARK; + const isHighContrast = highContrastIntent ?? contrastThemes.includes(preferredTheme ?? ''); + const signInTheme = isHighContrast ? CONST.THEME.DARK_CONTRAST : CONST.THEME.DARK; return (