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;