diff --git a/examples/build.md b/examples/build.md
index 2691c081..5b338012 100644
--- a/examples/build.md
+++ b/examples/build.md
@@ -155,7 +155,9 @@ Use the `OneSignal` object from `react-native-onesignal`:
| SendUniqueOutcome(name) | `OneSignal.Session.addUniqueOutcome(name)` |
| SendOutcomeWithValue(name, value) | `OneSignal.Session.addOutcomeWithValue(name, value)` |
| TrackEvent(name, properties) | `OneSignal.User.trackEvent(name, properties)` |
+| SetLanguage(language) | `OneSignal.User.setLanguage(language)` |
| GetPushSubscriptionId() | `await OneSignal.User.pushSubscription.getIdAsync()` |
+| GetPushSubscriptionToken() | `await OneSignal.User.pushSubscription.getTokenAsync()` |
| IsPushOptedIn() | `await OneSignal.User.pushSubscription.getOptedInAsync()` |
| OptInPush() | `OneSignal.User.pushSubscription.optIn()` |
| OptOutPush() | `OneSignal.User.pushSubscription.optOut()` |
@@ -232,6 +234,7 @@ OneSignal.User.addEventListener('change', handler);
### Persistence
- `PreferencesService` wraps `AsyncStorage`
+- Selected language is persisted for the User section dropdown only; the native SDK keeps its own language, so it is not re-applied on launch. Login and logout reset the picker to Device Default by clearing the cached value only. They don't call `setLanguage`, since `''` sends the device language and would overwrite the user's backend value.
- Triggers list (`triggersList`) is NOT persisted
### SDK State Restoration
diff --git a/examples/demo/src/components/DropdownMenu.tsx b/examples/demo/src/components/DropdownMenu.tsx
new file mode 100644
index 00000000..31093c5a
--- /dev/null
+++ b/examples/demo/src/components/DropdownMenu.tsx
@@ -0,0 +1,128 @@
+import React from 'react';
+import {
+ Modal,
+ Platform,
+ Pressable,
+ ScrollView,
+ StatusBar,
+ StyleSheet,
+ Text,
+ TouchableOpacity,
+ View,
+ useWindowDimensions,
+} from 'react-native';
+
+import { AppColors, AppTextStyles } from '../theme';
+
+export interface DropdownOption {
+ label: string;
+ value: string;
+}
+
+export interface DropdownAnchor {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+interface Props {
+ anchor: DropdownAnchor | null;
+ options: DropdownOption[];
+ selectedValue: string;
+ onSelect: (value: string) => void;
+ onClose: () => void;
+ testIDPrefix?: string;
+}
+
+const MENU_WIDTH = 240;
+const MENU_MAX_HEIGHT = 320;
+const SCREEN_MARGIN = 16;
+
+export default function DropdownMenu({
+ anchor,
+ options,
+ selectedValue,
+ onSelect,
+ onClose,
+ testIDPrefix,
+}: Props) {
+ const { width: screenWidth, height: screenHeight } = useWindowDimensions();
+
+ if (!anchor) {
+ return null;
+ }
+
+ // measureInWindow excludes the Android status bar, but the translucent
+ // modal draws under it.
+ const offsetY = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0;
+ const anchorTop = anchor.y + offsetY;
+ const anchorBottom = anchorTop + anchor.height;
+ // Open upward when the menu would run past the bottom of the screen.
+ const opensUp = anchorBottom + MENU_MAX_HEIGHT > screenHeight - SCREEN_MARGIN;
+ const position = {
+ right: Math.max(SCREEN_MARGIN, screenWidth - (anchor.x + anchor.width)),
+ ...(opensUp ? { bottom: screenHeight - anchorTop } : { top: anchorBottom }),
+ };
+
+ const handleSelect = (value: string) => {
+ onSelect(value);
+ onClose();
+ };
+
+ return (
+
+
+
+
+ {options.map(({ label, value }) => {
+ const isSelected = value === selectedValue;
+ return (
+ handleSelect(value)}
+ testID={testIDPrefix ? `${testIDPrefix}_${value || 'default'}` : undefined}
+ >
+
+ {label}
+
+
+ );
+ })}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ menu: {
+ position: 'absolute',
+ width: MENU_WIDTH,
+ maxHeight: MENU_MAX_HEIGHT,
+ backgroundColor: AppColors.osCardBackground,
+ borderRadius: 8,
+ elevation: 8,
+ shadowColor: '#000',
+ shadowOpacity: 0.2,
+ shadowRadius: 8,
+ shadowOffset: { width: 0, height: 4 },
+ },
+ scroll: {
+ borderRadius: 8,
+ paddingVertical: 8,
+ },
+ item: {
+ paddingVertical: 12,
+ paddingHorizontal: 16,
+ },
+ itemLabel: {
+ ...AppTextStyles.bodyLarge,
+ color: '#212121',
+ },
+ itemLabelSelected: {
+ color: AppColors.osPrimary,
+ fontWeight: '500',
+ },
+});
diff --git a/examples/demo/src/components/sections/PushSection.tsx b/examples/demo/src/components/sections/PushSection.tsx
index 56e52c78..0c45f9b1 100644
--- a/examples/demo/src/components/sections/PushSection.tsx
+++ b/examples/demo/src/components/sections/PushSection.tsx
@@ -8,6 +8,7 @@ import ToggleRow from '../ToggleRow';
interface Props {
pushSubscriptionId: string | undefined;
+ pushSubscriptionToken: string | undefined;
isPushEnabled: boolean;
hasNotificationPermission: boolean;
onSetPushEnabled: (value: boolean) => void;
@@ -17,12 +18,15 @@ interface Props {
export default function PushSection({
pushSubscriptionId,
+ pushSubscriptionToken,
isPushEnabled,
hasNotificationPermission,
onSetPushEnabled,
onPromptPush,
onInfoTap,
}: Props) {
+ const isSubscribed = isPushEnabled && hasNotificationPermission && !!pushSubscriptionToken;
+
return (
@@ -38,8 +42,15 @@ export default function PushSection({
+
+ Status
+
+ {isSubscribed ? 'Subscribed' : 'Unsubscribed'}
+
+
+
Promise;
onLogout: () => Promise;
+ onSetLanguage: (language: string) => Promise;
}
-export default function UserSection({ externalUserId, onLogin, onLogout }: Props) {
+export default function UserSection({
+ externalUserId,
+ language,
+ onLogin,
+ onLogout,
+ onSetLanguage,
+}: Props) {
const [loginOpen, setLoginOpen] = useState(false);
+ const [languageAnchor, setLanguageAnchor] = useState(null);
+ const languageRowRef = useRef(null);
const isLoggedIn = !!externalUserId;
+ const languageLabel =
+ LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ?? language;
const handleLogin = async (userId: string) => {
await onLogin(userId);
@@ -24,6 +85,12 @@ export default function UserSection({ externalUserId, onLogin, onLogout }: Props
await onLogout();
};
+ const openLanguageMenu = () => {
+ languageRowRef.current?.measureInWindow((x, y, width, height) => {
+ setLanguageAnchor({ x, y, width, height });
+ });
+ };
+
return (
@@ -43,6 +110,21 @@ export default function UserSection({ externalUserId, onLogin, onLogout }: Props
{externalUserId ?? '—'}
+
+
+ Language
+
+
+ {languageLabel}
+
+
+
+
setLoginOpen(false)}
testID="login_user_id_input"
/>
+ setLanguageAnchor(null)}
+ testIDPrefix="language_option"
+ />
);
}
@@ -89,6 +179,14 @@ const styles = StyleSheet.create({
color: AppColors.osGrey700,
fontFamily: 'monospace',
},
+ dropdownValue: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ languageValue: {
+ ...AppTextStyles.bodySmall,
+ color: AppColors.osGrey700,
+ },
loggedInText: {
color: AppColors.osSuccess,
},
diff --git a/examples/demo/src/hooks/useOneSignal.ts b/examples/demo/src/hooks/useOneSignal.ts
index 4a37816b..eb1d4707 100644
--- a/examples/demo/src/hooks/useOneSignal.ts
+++ b/examples/demo/src/hooks/useOneSignal.ts
@@ -66,10 +66,12 @@ export type UseOneSignalReturn = {
externalUserId: string | undefined;
oneSignalId: string | undefined;
pushSubscriptionId: string | undefined;
+ pushSubscriptionToken: string | undefined;
isPushEnabled: boolean;
hasNotificationPermission: boolean;
inAppMessagesPaused: boolean;
locationShared: boolean;
+ language: string;
aliasesList: [string, string][];
emailsList: string[];
smsNumbersList: string[];
@@ -79,6 +81,7 @@ export type UseOneSignalReturn = {
isReady: boolean;
loginUser: (externalUserId: string) => Promise;
logoutUser: () => Promise;
+ setLanguage: (language: string) => Promise;
setConsentRequired: (required: boolean) => Promise;
setConsentGiven: (granted: boolean) => Promise;
promptPush: () => Promise;
@@ -120,10 +123,12 @@ function useOneSignalState(): UseOneSignalReturn {
const [externalUserId, setExternalUserId] = useState(undefined);
const [oneSignalId, setOneSignalId] = useState(undefined);
const [pushSubscriptionId, setPushSubscriptionId] = useState(undefined);
+ const [pushSubscriptionToken, setPushSubscriptionToken] = useState(undefined);
const [isPushEnabled, setIsPushEnabled] = useState(false);
const [hasNotificationPermission, setHasNotificationPermission] = useState(false);
const [inAppMessagesPaused, setInAppMessagesPaused] = useState(false);
const [locationShared, setLocationSharedState] = useState(false);
+ const [language, setLanguageState] = useState('');
const [aliasesList, setAliasesList] = useState<[string, string][]>([]);
const [emailsList, setEmailsList] = useState([]);
const [smsNumbersList, setSmsNumbersList] = useState([]);
@@ -236,6 +241,7 @@ function useOneSignalState(): UseOneSignalReturn {
`Push subscription changed: id=${previous.id ?? 'null'} → ${current.id ?? 'null'}, optedIn=${previous.optedIn} → ${current.optedIn}, token=${fmtToken(previous.token)} → ${fmtToken(current.token)}`,
);
setPushSubscriptionId(current.id ?? undefined);
+ setPushSubscriptionToken(current.token ?? undefined);
setIsPushEnabled(current.optedIn);
};
@@ -266,13 +272,19 @@ function useOneSignalState(): UseOneSignalReturn {
const load = async () => {
const nextAppId = resolveAppId();
- const [nextConsentRequired, nextPrivacyConsentGiven, nextIamPaused, nextLocationShared] =
- await Promise.all([
- preferences.getConsentRequired(),
- preferences.getPrivacyConsent(),
- preferences.getIamPaused(),
- preferences.getLocationShared(),
- ]);
+ const [
+ nextConsentRequired,
+ nextPrivacyConsentGiven,
+ nextIamPaused,
+ nextLocationShared,
+ nextLanguage,
+ ] = await Promise.all([
+ preferences.getConsentRequired(),
+ preferences.getPrivacyConsent(),
+ preferences.getIamPaused(),
+ preferences.getLocationShared(),
+ preferences.getLanguage(),
+ ]);
const storedExternalUserId = (await preferences.getExternalUserId()) ?? undefined;
if (cancelled) return;
@@ -312,13 +324,15 @@ function useOneSignalState(): UseOneSignalReturn {
log.info(`Initialized with app ID: ${nextAppId}`);
- const [externalId, initialOnesignalId, pushId, pushOptedIn, hasPerm] = await Promise.all([
- OneSignal.User.getExternalId(),
- OneSignal.User.getOnesignalId(),
- OneSignal.User.pushSubscription.getIdAsync(),
- OneSignal.User.pushSubscription.getOptedInAsync(),
- OneSignal.Notifications.getPermissionAsync(),
- ]);
+ const [externalId, initialOnesignalId, pushId, pushToken, pushOptedIn, hasPerm] =
+ await Promise.all([
+ OneSignal.User.getExternalId(),
+ OneSignal.User.getOnesignalId(),
+ OneSignal.User.pushSubscription.getIdAsync(),
+ OneSignal.User.pushSubscription.getTokenAsync(),
+ OneSignal.User.pushSubscription.getOptedInAsync(),
+ OneSignal.Notifications.getPermissionAsync(),
+ ]);
if (cancelled) return;
setAppId(nextAppId);
@@ -326,8 +340,10 @@ function useOneSignalState(): UseOneSignalReturn {
setPrivacyConsentGivenState(nextPrivacyConsentGiven);
setInAppMessagesPaused(nextIamPaused);
setLocationSharedState(nextLocationShared);
+ setLanguageState(nextLanguage);
if (!pushChanged) {
setPushSubscriptionId(pushId ?? undefined);
+ setPushSubscriptionToken(pushToken ?? undefined);
setIsPushEnabled(pushOptedIn);
}
if (!permissionChanged) setHasNotificationPermission(hasPerm);
@@ -365,6 +381,13 @@ function useOneSignalState(): UseOneSignalReturn {
};
}, [fetchUserDataFromApi]);
+ // Clears only the cached choice. setLanguage('') would send the device
+ // language and overwrite the user's backend value.
+ const resetLanguagePicker = async () => {
+ setLanguageState('');
+ await preferences.setLanguage('');
+ };
+
const loginUser = async (nextExternalUserId: string) => {
requestSequenceRef.current++;
setAliasesList([]);
@@ -378,6 +401,7 @@ function useOneSignalState(): UseOneSignalReturn {
OneSignal.login(nextExternalUserId);
await preferences.setExternalUserId(nextExternalUserId);
setExternalUserId(nextExternalUserId);
+ await resetLanguagePicker();
log.info(`Logged in as: ${nextExternalUserId}`);
// The user 'change' listener runs fetchUserDataFromApi once the new
// onesignalId is assigned; that call clears isLoading in its finally.
@@ -387,6 +411,14 @@ function useOneSignalState(): UseOneSignalReturn {
}
};
+ // An empty code tells the native SDK to fall back to the device language.
+ const setLanguage = async (nextLanguage: string) => {
+ OneSignal.User.setLanguage(nextLanguage);
+ setLanguageState(nextLanguage);
+ await preferences.setLanguage(nextLanguage);
+ log.info(`Language set to: ${nextLanguage || 'device default'}`);
+ };
+
const logoutUser = async () => {
requestSequenceRef.current++;
setIsLoading(false);
@@ -399,6 +431,7 @@ function useOneSignalState(): UseOneSignalReturn {
setTagsList([]);
setTriggersList([]);
log.info('Logged out');
+ await resetLanguagePicker();
};
const setConsentRequired = async (required: boolean) => {
@@ -596,10 +629,12 @@ function useOneSignalState(): UseOneSignalReturn {
externalUserId,
oneSignalId,
pushSubscriptionId,
+ pushSubscriptionToken,
isPushEnabled,
hasNotificationPermission,
inAppMessagesPaused,
locationShared,
+ language,
aliasesList,
emailsList,
smsNumbersList,
@@ -609,6 +644,7 @@ function useOneSignalState(): UseOneSignalReturn {
isReady,
loginUser,
logoutUser,
+ setLanguage,
setConsentRequired,
setConsentGiven,
promptPush,
diff --git a/examples/demo/src/screens/HomeScreen.tsx b/examples/demo/src/screens/HomeScreen.tsx
index d6b7f9e3..62e253db 100644
--- a/examples/demo/src/screens/HomeScreen.tsx
+++ b/examples/demo/src/screens/HomeScreen.tsx
@@ -68,12 +68,15 @@ export default function HomeScreen() {
{
await AsyncStorage.setItem(KEYS.IAM_PAUSED, String(paused));
}
+
+ async getLanguage(): Promise {
+ return (await AsyncStorage.getItem(KEYS.LANGUAGE)) ?? '';
+ }
+
+ async setLanguage(language: string): Promise {
+ await AsyncStorage.setItem(KEYS.LANGUAGE, language);
+ }
}
export default PreferencesService;