From 39ea97215cc6b8e0888300116560f9a316f7f2af Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 26 Oct 2026 19:37:13 -0700 Subject: [PATCH 1/4] chore: [SDK-5609] standardize push subscription UI in the React Native demo Co-Authored-By: Claude Opus 5.5 --- examples/build.md | 3 + .../components/modals/SingleSelectModal.tsx | 89 +++++++++++++++++ .../src/components/sections/PushSection.tsx | 17 +++- .../src/components/sections/UserSection.tsx | 95 ++++++++++++++++++- examples/demo/src/hooks/useOneSignal.ts | 56 ++++++++--- examples/demo/src/screens/HomeScreen.tsx | 3 + .../demo/src/services/PreferencesService.ts | 9 ++ 7 files changed, 255 insertions(+), 17 deletions(-) create mode 100644 examples/demo/src/components/modals/SingleSelectModal.tsx diff --git a/examples/build.md b/examples/build.md index 2691c081..612a8558 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. Logout resets it to Device Default (`''`). - Triggers list (`triggersList`) is NOT persisted ### SDK State Restoration diff --git a/examples/demo/src/components/modals/SingleSelectModal.tsx b/examples/demo/src/components/modals/SingleSelectModal.tsx new file mode 100644 index 00000000..475ec300 --- /dev/null +++ b/examples/demo/src/components/modals/SingleSelectModal.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { Modal, View, Text, TouchableOpacity, ScrollView, StyleSheet } from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialIcons'; + +import { AppColors, AppTextStyles, AppDialogStyles } from '../../theme'; + +export interface SelectOption { + label: string; + value: string; +} + +interface Props { + visible: boolean; + title: string; + options: SelectOption[]; + selectedValue: string; + onSelect: (value: string) => void; + onClose: () => void; + testIDPrefix?: string; +} + +export default function SingleSelectModal({ + visible, + title, + options, + selectedValue, + onSelect, + onClose, + testIDPrefix, +}: Props) { + const handleSelect = (value: string) => { + onSelect(value); + onClose(); + }; + + return ( + + + + {title} + + {options.map(({ label, value }) => { + const isSelected = value === selectedValue; + return ( + handleSelect(value)} + testID={testIDPrefix ? `${testIDPrefix}_${value || 'default'}` : undefined} + > + + {label} + + ); + })} + + + + Cancel + + + + + + ); +} + +const styles = StyleSheet.create({ + containerMaxHeight: { + maxHeight: '70%', + }, + scroll: { + maxHeight: 400, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + gap: 12, + }, + itemLabel: { + ...AppTextStyles.bodyLarge, + color: '#212121', + }, +}); 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 [languageOpen, setLanguageOpen] = useState(false); const isLoggedIn = !!externalUserId; + const languageLabel = + LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ?? language; const handleLogin = async (userId: string) => { await onLogin(userId); @@ -43,6 +103,20 @@ export default function UserSection({ externalUserId, onLogin, onLogout }: Props {externalUserId ?? '—'} + + setLanguageOpen(true)} + testID="user_language_dropdown" + > + Language + + + {languageLabel} + + + + setLoginOpen(false)} testID="login_user_id_input" /> + setLanguageOpen(false)} + testIDPrefix="language_option" + /> ); } @@ -89,6 +172,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..c365a60d 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); @@ -387,6 +403,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 +423,7 @@ function useOneSignalState(): UseOneSignalReturn { setTagsList([]); setTriggersList([]); log.info('Logged out'); + await setLanguage(''); }; const setConsentRequired = async (required: boolean) => { @@ -596,10 +621,12 @@ function useOneSignalState(): UseOneSignalReturn { externalUserId, oneSignalId, pushSubscriptionId, + pushSubscriptionToken, isPushEnabled, hasNotificationPermission, inAppMessagesPaused, locationShared, + language, aliasesList, emailsList, smsNumbersList, @@ -609,6 +636,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; From 25e0454a63385790ddd8c7965d5b7d94eeb4cc42 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 27 Oct 2026 13:16:22 -0700 Subject: [PATCH 2/4] chore: [SDK-5609] cap the language menu at 320px Co-Authored-By: Claude Opus 5.5 --- examples/demo/src/components/modals/SingleSelectModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/demo/src/components/modals/SingleSelectModal.tsx b/examples/demo/src/components/modals/SingleSelectModal.tsx index 475ec300..b3ee5fbe 100644 --- a/examples/demo/src/components/modals/SingleSelectModal.tsx +++ b/examples/demo/src/components/modals/SingleSelectModal.tsx @@ -74,7 +74,7 @@ const styles = StyleSheet.create({ maxHeight: '70%', }, scroll: { - maxHeight: 400, + maxHeight: 320, }, row: { flexDirection: 'row', From 61ba6202c060c8e5b8afa62fa47e516cc06d129b Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 27 Oct 2026 13:38:58 -0700 Subject: [PATCH 3/4] chore: [SDK-5609] reset language picker on login and logout without setLanguage Co-Authored-By: Claude Opus 5.5 --- examples/build.md | 2 +- examples/demo/src/hooks/useOneSignal.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/examples/build.md b/examples/build.md index 612a8558..5b338012 100644 --- a/examples/build.md +++ b/examples/build.md @@ -234,7 +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. Logout resets it to Device Default (`''`). +- 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/hooks/useOneSignal.ts b/examples/demo/src/hooks/useOneSignal.ts index c365a60d..eb1d4707 100644 --- a/examples/demo/src/hooks/useOneSignal.ts +++ b/examples/demo/src/hooks/useOneSignal.ts @@ -381,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([]); @@ -394,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. @@ -423,7 +431,7 @@ function useOneSignalState(): UseOneSignalReturn { setTagsList([]); setTriggersList([]); log.info('Logged out'); - await setLanguage(''); + await resetLanguagePicker(); }; const setConsentRequired = async (required: boolean) => { From c70362a084be58e93b145f63ca6b8094c92a7bff Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 27 Oct 2026 15:56:30 -0700 Subject: [PATCH 4/4] chore: [SDK-5609] anchor the language picker as a dropdown menu Co-Authored-By: Claude Opus 5.5 --- examples/demo/src/components/DropdownMenu.tsx | 128 ++++++++++++++++++ .../components/modals/SingleSelectModal.tsx | 89 ------------ .../src/components/sections/UserSection.tsx | 25 ++-- 3 files changed, 144 insertions(+), 98 deletions(-) create mode 100644 examples/demo/src/components/DropdownMenu.tsx delete mode 100644 examples/demo/src/components/modals/SingleSelectModal.tsx 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/modals/SingleSelectModal.tsx b/examples/demo/src/components/modals/SingleSelectModal.tsx deleted file mode 100644 index b3ee5fbe..00000000 --- a/examples/demo/src/components/modals/SingleSelectModal.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import React from 'react'; -import { Modal, View, Text, TouchableOpacity, ScrollView, StyleSheet } from 'react-native'; -import Icon from 'react-native-vector-icons/MaterialIcons'; - -import { AppColors, AppTextStyles, AppDialogStyles } from '../../theme'; - -export interface SelectOption { - label: string; - value: string; -} - -interface Props { - visible: boolean; - title: string; - options: SelectOption[]; - selectedValue: string; - onSelect: (value: string) => void; - onClose: () => void; - testIDPrefix?: string; -} - -export default function SingleSelectModal({ - visible, - title, - options, - selectedValue, - onSelect, - onClose, - testIDPrefix, -}: Props) { - const handleSelect = (value: string) => { - onSelect(value); - onClose(); - }; - - return ( - - - - {title} - - {options.map(({ label, value }) => { - const isSelected = value === selectedValue; - return ( - handleSelect(value)} - testID={testIDPrefix ? `${testIDPrefix}_${value || 'default'}` : undefined} - > - - {label} - - ); - })} - - - - Cancel - - - - - - ); -} - -const styles = StyleSheet.create({ - containerMaxHeight: { - maxHeight: '70%', - }, - scroll: { - maxHeight: 320, - }, - row: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 10, - gap: 12, - }, - itemLabel: { - ...AppTextStyles.bodyLarge, - color: '#212121', - }, -}); diff --git a/examples/demo/src/components/sections/UserSection.tsx b/examples/demo/src/components/sections/UserSection.tsx index 7ebc8ae1..614f104d 100644 --- a/examples/demo/src/components/sections/UserSection.tsx +++ b/examples/demo/src/components/sections/UserSection.tsx @@ -1,14 +1,14 @@ -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; import Icon from 'react-native-vector-icons/MaterialIcons'; import { AppColors, AppTextStyles, AppTheme, AppSpacing } from '../../theme'; import ActionButton from '../ActionButton'; +import DropdownMenu, { type DropdownAnchor, type DropdownOption } from '../DropdownMenu'; import SingleInputModal from '../modals/SingleInputModal'; -import SingleSelectModal, { type SelectOption } from '../modals/SingleSelectModal'; import SectionCard from '../SectionCard'; -const LANGUAGE_OPTIONS: SelectOption[] = [ +const LANGUAGE_OPTIONS: DropdownOption[] = [ { label: 'Device Default', value: '' }, { label: 'English', value: 'en' }, { label: 'Arabic', value: 'ar' }, @@ -71,7 +71,8 @@ export default function UserSection({ onSetLanguage, }: Props) { const [loginOpen, setLoginOpen] = useState(false); - const [languageOpen, setLanguageOpen] = 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; @@ -84,6 +85,12 @@ export default function UserSection({ await onLogout(); }; + const openLanguageMenu = () => { + languageRowRef.current?.measureInWindow((x, y, width, height) => { + setLanguageAnchor({ x, y, width, height }); + }); + }; + return ( @@ -105,8 +112,9 @@ export default function UserSection({ setLanguageOpen(true)} + onPress={openLanguageMenu} testID="user_language_dropdown" > Language @@ -140,13 +148,12 @@ export default function UserSection({ onClose={() => setLoginOpen(false)} testID="login_user_id_input" /> - setLanguageOpen(false)} + onClose={() => setLanguageAnchor(null)} testIDPrefix="language_option" />