Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
11040f9
add highcontrast btn in signin page and sync it with server values
rushatgabhane May 29, 2026
7f87a7e
Apply suggestions from code review
rushatgabhane May 29, 2026
60f88c0
add a11y label to icon
rushatgabhane May 29, 2026
2069610
Merge branch 'home-high-contrast' of github.com:rushatgabhane/exfy in…
rushatgabhane May 29, 2026
e8db467
add maxwidth 150px
rushatgabhane May 29, 2026
8f50409
fix lint
rushatgabhane May 29, 2026
d5c3631
Merge branch 'main' into home-high-contrast
rushatgabhane Jun 1, 2026
818ef0e
move to new hook
rushatgabhane Jun 1, 2026
85a6f54
use moon icon
rushatgabhane Jun 1, 2026
bf5e188
rm fixed width
rushatgabhane Jun 1, 2026
a2608a2
use const
rushatgabhane Jun 1, 2026
6bfeaf0
add disable
rushatgabhane Jun 1, 2026
4e32720
Apply suggestions from code review
rushatgabhane Jun 1, 2026
b827473
fix lint
rushatgabhane Jun 2, 2026
e9e9da3
Merge branch 'home-high-contrast' of github.com:rushatgabhane/exfy in…
rushatgabhane Jun 2, 2026
6e86a76
use theme.icon
rushatgabhane Jun 2, 2026
8854a3a
Merge branch 'Expensify:main' into home-high-contrast
rushatgabhane Jun 2, 2026
f3e8202
accept false vlaues
rushatgabhane Jun 2, 2026
fd28842
Merge branch 'home-high-contrast' of github.com:rushatgabhane/exfy in…
rushatgabhane Jun 2, 2026
490dc31
Merge branch 'Expensify:main' into home-high-contrast
rushatgabhane Jun 3, 2026
b93685c
Merge branch 'Expensify:main' into home-high-contrast
rushatgabhane Jun 4, 2026
239c21f
anonymous account - local only
rushatgabhane Jun 4, 2026
48de65f
fix signin
rushatgabhane Jun 5, 2026
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
1 change: 1 addition & 0 deletions assets/images/moon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
5 changes: 5 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down Expand Up @@ -1553,6 +1557,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[];
[ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string;
[ONYXKEYS.PREFERRED_THEME]: ValueOf<typeof CONST.THEME>;
[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;
Expand Down
53 changes: 53 additions & 0 deletions src/components/HighContrastModeSwitcher.tsx
Comment thread
rushatgabhane marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The button state should be consistent with the UI, which takes the user's intended theme into account:

const isHighContrast = highContrastIntent ?? contrastThemes.includes(preferredTheme ?? '');

Fixed via #92978.

const label = translate(isHighContrast ? 'themePage.disableHighContrast' : 'themePage.enableHighContrast');

const toggleHighContrast = () => {
const baseTheme = getBaseTheme(currentTheme);
updateTheme(isHighContrast ? baseTheme : getContrastTheme(baseTheme), false);
setHighContrastIntent(!isHighContrast);
};
Comment thread
rushatgabhane marked this conversation as resolved.

return (
<PressableWithFeedback
onPress={toggleHighContrast}
sentryLabel={CONST.SENTRY_LABEL.HIGH_CONTRAST_MODE_SWITCHER.TOGGLE}
role={CONST.ROLE.BUTTON}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think the role of this element is incorrect here, since it's essentially a Switch(CONST.ROLE.SWITCH), just not a very standard one. I could be wrong, but if the role is button, then checked can be ignored.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@MelvinBot according to wcag 2.2 what is the correct role here

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@/tmp/melvinbot-response.md

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I agree with you. But if we change it to switch role, then we'll have to add a visual indicator for the switch - to indicate on/off state 😓

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

All right, I think we can leave it as is, but I'm not sure if we need checked in that case. What do you think?

accessibilityLabel={label}
style={[styles.flexRow, styles.alignItemsCenter]}
>
<Icon
src={icons.Moon}
fill={theme.icon}
width={variables.iconSizeSmall}
height={variables.iconSizeSmall}
accessibilityLabel={label}
/>
<Text style={[styles.textSmall, styles.ml2]}>{label}</Text>
</PressableWithFeedback>
);
}

export default HighContrastModeSwitcher;
2 changes: 2 additions & 0 deletions src/components/Icon/chunks/expensify-icons.chunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -416,6 +417,7 @@ const Expensicons = {
MoneyWaving,
MoneyHourglass,
Monitor,
Moon,
Mute,
ExpensifyLogoNew,
NewWindow,
Expand Down
35 changes: 35 additions & 0 deletions src/hooks/useReconcileHighContrastIntent.ts
Original file line number Diff line number Diff line change
@@ -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<boolean | undefined>(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;
2 changes: 2 additions & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3007,6 +3007,8 @@ const translations = {
},
},
highContrastMode: 'High contrast mode',
enableHighContrast: 'Enable high contrast',
Comment thread
rushatgabhane marked this conversation as resolved.
disableHighContrast: 'Disable high contrast',
chooseThemeBelowOrSync: 'Choose a theme below, or sync with your device settings.',
},
termsOfUse: {
Expand Down
2 changes: 2 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2898,6 +2898,8 @@ ${date} の ${merchant} への ${amount}`,
},
},
highContrastMode: 'ハイコントラストモード',
enableHighContrast: 'ハイコントラストを有効にする',
disableHighContrast: 'ハイコントラストを無効にする',
chooseThemeBelowOrSync: '以下からテーマを選択するか、デバイスの設定と同期してください。',
},
termsOfUse: {
Expand Down
2 changes: 2 additions & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2841,6 +2841,8 @@ ${amount},商户:${merchant} - 日期:${date}`,
},
},
highContrastMode: '高对比度模式',
enableHighContrast: '启用高对比度',
disableHighContrast: '关闭高对比度',
chooseThemeBelowOrSync: '请选择下方的主题,或与您的设备设置同步。',
},
termsOfUse: {
Expand Down
3 changes: 3 additions & 0 deletions src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -73,6 +74,8 @@ function AuthScreensInitHandler() {
const reportAttributesRef = useRef(reportAttributes);
reportAttributesRef.current = reportAttributes;

useReconcileHighContrastIntent();

useEffect(() => {
if (!Navigation.isActiveRoute(ROUTES.SIGN_IN_MODAL)) {
return;
Expand Down
1 change: 1 addition & 0 deletions src/libs/actions/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 21 additions & 1 deletion src/libs/actions/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`>
Expand Down Expand Up @@ -1214,6 +1223,12 @@ function setContactMethodAsDefault(
}

function updateTheme(theme: ValueOf<typeof CONST.THEME>, 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<OnyxUpdate<typeof ONYXKEYS.PREFERRED_THEME>> = [
{
onyxMethod: Onyx.METHOD.SET,
Expand All @@ -1233,6 +1248,10 @@ function updateTheme(theme: ValueOf<typeof CONST.THEME>, shouldGoBack = true) {
}
}

function setHighContrastIntent(hasIntent: boolean | null) {
Onyx.set(ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT, hasIntent);
}

/**
* Sets a custom status
*/
Expand Down Expand Up @@ -1914,6 +1933,7 @@ export {
updateChatPriorityMode,
setContactMethodAsDefault,
updateTheme,
setHighContrastIntent,
resetContactMethodValidateCodeSentState,
updateCustomStatus,
clearCustomStatus,
Expand Down
4 changes: 4 additions & 0 deletions src/pages/signin/Licenses.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,6 +19,9 @@ function Licenses() {
<RenderHTML html={`<muted-text-xs>${translate('termsOfUse.license')}</muted-text-xs>`} />
</View>
<View style={[styles.mt4, styles.alignItemsCenter, styles.mb2, styles.flexRow, styles.justifyContentBetween]}>
<HighContrastModeSwitcher />
</View>
<View style={[styles.alignItemsCenter, styles.mb2, styles.flexRow, styles.justifyContentBetween]}>
<LocalePicker size="small" />
</View>
</>
Expand Down
4 changes: 3 additions & 1 deletion src/pages/signin/SignInPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -360,8 +360,10 @@ function SignInPageWrapper({ref}: SignInPageProps) {
function WithTheme(Component: React.ComponentType<SignInPageProps>) {
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 (
<ThemeProvider theme={signInTheme}>
Expand Down
Loading