diff --git a/packages/shared/src/contexts/PushNotificationContext.tsx b/packages/shared/src/contexts/PushNotificationContext.tsx index fd007640d5..e4fd6ee014 100644 --- a/packages/shared/src/contexts/PushNotificationContext.tsx +++ b/packages/shared/src/contexts/PushNotificationContext.tsx @@ -1,14 +1,14 @@ import type { ReactElement } from 'react'; import React, { + useRef, + useEffect, createContext, useCallback, useContext, - useEffect, - useRef, useState, } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import type OneSignal from 'react-onesignal'; +import OneSignal from 'react-onesignal'; import type { NotificationPromptSource } from '../lib/log'; import { LogEvent } from '../lib/log'; import { checkIsExtension, disabledRefetch } from '../lib/func'; @@ -19,14 +19,13 @@ import { useLogContext } from './LogContext'; import type { SubscriptionCallback } from '../components/notifications/utils'; export interface PushNotificationsContextData { - OneSignal: typeof OneSignal; isPushSupported: boolean; isInitialized: boolean; isSubscribed: boolean; isLoading: boolean; - shouldOpenPopup: boolean; - onSourceChange: (source: string) => void; - logPermissionGranted: (source: NotificationPromptSource) => void; + shouldOpenPopup: () => boolean; + subscribe: (source: NotificationPromptSource) => Promise; + unsubscribe: (source: NotificationPromptSource) => Promise; } export const PushNotificationsContext = @@ -35,10 +34,9 @@ export const PushNotificationsContext = isInitialized: true, isSubscribed: false, isLoading: false, - shouldOpenPopup: true, - logPermissionGranted: null, - onSourceChange: null, - OneSignal: null, + shouldOpenPopup: () => true, + subscribe: null, + unsubscribe: null, }); interface PushNotificationContextProviderProps { @@ -46,43 +44,20 @@ interface PushNotificationContextProviderProps { } type ChangeEventHandler = Parameters< - typeof OneSignal.User.PushSubscription.addEventListener + typeof OneSignal.Notifications.addEventListener<'permissionChange'> >[1]; -/** - * This context provider should only be used in the webapp - */ -export function PushNotificationContextProvider({ +function OneSignalSubProvider({ children, }: PushNotificationContextProviderProps): ReactElement { - const isExtension = checkIsExtension(); - const notificationSourceRef = useRef(); - const onSourceChange = useCallback((source) => { - notificationSourceRef.current = source; - }, []); const [isSubscribed, setIsSubscribed] = useState(false); const { user } = useAuthContext(); const { logEvent } = useLogContext(); - const subscriptionCallback: SubscriptionCallback = ( - isSubscribedNew, - source, - existing_permission, - ) => { - if (isSubscribedNew) { - logEvent({ - event_name: LogEvent.ClickEnableNotification, - extra: JSON.stringify({ - origin: source || notificationSourceRef.current, - permission: 'granted', - ...(existing_permission && { existing_permission }), - }), - }); - } - }; - const subscriptionCallbackRef = - useRef(subscriptionCallback); - subscriptionCallbackRef.current = subscriptionCallback; - const isEnabled = !!user && !isTesting && !isExtension; + const sourceRef = useRef(); + const subscriptionCallbackRef = useRef(); + + const isEnabled = !!user && !isTesting; + const key = generateQueryKey(RequestKey.OneSignal, user); const client = useQueryClient(); const { @@ -100,69 +75,106 @@ export function PushNotificationContextProvider({ return osr; } - const OneSingalImport = (await import('react-onesignal')).default; + const OneSignalImport = (await import('react-onesignal')).default; - await OneSingalImport.init({ + await OneSignalImport.init({ appId: process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID, serviceWorkerParam: { scope: '/push/onesignal/' }, serviceWorkerPath: '/push/onesignal/OneSignalSDKWorker.js', }); - await OneSingalImport.login(user.id); + await OneSignalImport.login(user.id); - setIsSubscribed(OneSingalImport.User.PushSubscription.optedIn); + setIsSubscribed(OneSignalImport.User.PushSubscription.optedIn); - return OneSingalImport; + return OneSignalImport; }, enabled: isEnabled, ...disabledRefetch, }); - const isPushSupported = - !!globalThis.window?.Notification && - OneSignalCache?.Notifications.isPushSupported(); + const isPushSupported = OneSignalCache?.Notifications.isPushSupported(); + + subscriptionCallbackRef.current = async ( + newPermission, + source, + existingPermission, + ) => { + if (newPermission) { + await OneSignal.User.PushSubscription.optIn(); + setIsSubscribed(true); + + logEvent({ + event_name: LogEvent.ClickEnableNotification, + extra: JSON.stringify({ + origin: source || sourceRef.current, + provider: 'web', + permission: 'granted', + ...(existingPermission && { + existing_permission: existingPermission, + }), + }), + }); + } + }; + + const subscribe = useCallback( + async (source: NotificationPromptSource) => { + if (!OneSignalCache) { + return false; + } - const logPermissionGranted = useCallback( - (source) => subscriptionCallbackRef.current?.(true, source, true), - [], + sourceRef.current = source; + const { permission } = OneSignalCache.Notifications; + if (!permission) { + await OneSignalCache.Notifications.requestPermission(); + } else { + await subscriptionCallbackRef.current?.(true, source, true); + } + return OneSignalCache.Notifications.permission; + }, + [OneSignalCache], ); + const unsubscribe = useCallback(async () => { + if (!OneSignalCache) { + return; + } + await OneSignalCache.User.PushSubscription.optOut(); + setIsSubscribed(false); + }, [OneSignalCache]); + useEffect(() => { if (!OneSignalCache) { return undefined; } - const onChange: ChangeEventHandler = ({ current }) => { - setIsSubscribed(() => current.optedIn); - subscriptionCallbackRef.current?.(current.optedIn); + const onChange: ChangeEventHandler = (permission) => { + subscriptionCallbackRef.current?.(permission); }; - OneSignalCache.User.PushSubscription.addEventListener('change', onChange); + OneSignalCache.Notifications.addEventListener('permissionChange', onChange); return () => { - OneSignalCache.User.PushSubscription.removeEventListener( - 'change', + OneSignalCache.Notifications.removeEventListener( + 'permissionChange', onChange, ); }; }, [OneSignalCache]); - if (isExtension) { - throw new Error( - 'PushNotificationContextProvider should only be used in the webapp', - ); - } - return ( { + const { permission } = globalThis.Notification ?? {}; + return permission === 'denied'; + }, + subscribe, + unsubscribe, }} > {children} @@ -170,5 +182,106 @@ export function PushNotificationContextProvider({ ); } +function NativeAppleSubProvider({ + children, +}: PushNotificationContextProviderProps): ReactElement { + const [isSubscribed, setIsSubscribed] = useState(false); + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const isEnabled = !!user && !isTesting; + + const key = generateQueryKey(RequestKey.ApplePush, user); + const { isFetched, isLoading, isSuccess } = useQuery({ + queryKey: key, + + queryFn: async () => { + return new Promise((resolve) => { + globalThis.addEventListener( + 'push-state', + (event: CustomEvent) => { + setIsSubscribed(!!event?.detail); + resolve(); + }, + { once: true }, + ); + globalThis.webkit.messageHandlers['push-state'].postMessage(null); + globalThis.webkit.messageHandlers['push-user-id'].postMessage(user.id); + }); + }, + enabled: isEnabled, + ...disabledRefetch, + }); + + const subscribe = useCallback( + async (source: NotificationPromptSource) => { + return new Promise((resolve) => { + globalThis.addEventListener( + 'push-subscribe', + (event: CustomEvent) => { + const subscribed = !!event?.detail; + setIsSubscribed(subscribed); + if (subscribed) { + logEvent({ + event_name: LogEvent.ClickEnableNotification, + extra: JSON.stringify({ + origin: source, + provider: 'apple', + permission: 'granted', + }), + }); + } + resolve(subscribed); + }, + { once: true }, + ); + globalThis.webkit.messageHandlers['push-subscribe'].postMessage(null); + }); + }, + [logEvent], + ); + + const unsubscribe = useCallback(async () => { + globalThis.webkit.messageHandlers['push-unsubscribe'].postMessage(null); + setIsSubscribed(false); + }, []); + + return ( + false, + subscribe, + unsubscribe, + }} + > + {children} + + ); +} + +/** + * This context provider should only be used in the webapp + */ +export function PushNotificationContextProvider({ + children, +}: PushNotificationContextProviderProps): ReactElement { + const isExtension = checkIsExtension(); + + if (isExtension) { + throw new Error( + 'PushNotificationContextProvider should only be used in the webapp', + ); + } + + if (globalThis.webkit && globalThis.webkit.messageHandlers) { + return {children}; + } + + return {children}; +} + export const usePushNotificationContext = (): PushNotificationsContextData => useContext(PushNotificationsContext); diff --git a/packages/shared/src/hooks/notifications/usePushNotificationMutation.tsx b/packages/shared/src/hooks/notifications/usePushNotificationMutation.tsx index 2060ec9f0f..acfb1ab0d3 100644 --- a/packages/shared/src/hooks/notifications/usePushNotificationMutation.tsx +++ b/packages/shared/src/hooks/notifications/usePushNotificationMutation.tsx @@ -35,7 +35,7 @@ export const usePushNotificationMutation = ({ onPopupGranted, }: UsePushNotificationMutationProps = {}): UsePushNotificationMutation => { const isExtension = checkIsExtension(); - const { onSourceChange, OneSignal, isSubscribed, shouldOpenPopup } = + const { isSubscribed, shouldOpenPopup, subscribe, unsubscribe } = usePushNotificationContext(); const { user } = useAuthContext(); const [acceptedJustNow, onAcceptedJustNow] = useState(false); @@ -43,20 +43,15 @@ export const usePushNotificationMutation = ({ const [permissionCache, setPermissionCache] = usePermissionCache(); const onGranted = useCallback(async () => { - setPermissionCache('granted'); + await setPermissionCache('granted'); onAcceptedJustNow(true); if (!checkHasCompleted(ActionType.EnableNotification)) { - completeAction(ActionType.EnableNotification); - } - - if (OneSignal) { - await OneSignal.User.PushSubscription.optIn(); + await completeAction(ActionType.EnableNotification); } return true; }, [ - OneSignal, checkHasCompleted, completeAction, setPermissionCache, @@ -83,42 +78,30 @@ export const usePushNotificationMutation = ({ return false; } - const { permission } = globalThis.Notification ?? {}; - - if (shouldOpenPopup || permission === 'denied') { + if (shouldOpenPopup()) { onOpenPopup(source); return false; } - onSourceChange(source); - - if (permission === 'granted') { - return onGranted(); - } - - await OneSignal.Notifications.requestPermission(); - - const isGranted = OneSignal.Notifications.permission; - + const isGranted = await subscribe(source); if (isGranted) { await onGranted(); } return isGranted; }, - [user, shouldOpenPopup, onSourceChange, OneSignal, onOpenPopup, onGranted], + [user, shouldOpenPopup, subscribe, onOpenPopup, onGranted], ); const onTogglePermission = useCallback( async (source: NotificationPromptSource): Promise => { if (isSubscribed) { - onSourceChange(source); - return OneSignal.User.PushSubscription.optOut(); + return unsubscribe(source); } return onEnablePush(source); }, - [OneSignal, isSubscribed, onEnablePush, onSourceChange], + [isSubscribed, onEnablePush, unsubscribe], ); useEventListener(globalThis, 'message', async (e) => { @@ -133,7 +116,7 @@ export const usePushNotificationMutation = ({ return; } - onGranted(); + await onGranted(); if (onPopupGranted) { onPopupGranted(); diff --git a/packages/shared/src/lib/query.ts b/packages/shared/src/lib/query.ts index 4945f159f2..3f0a2fbc70 100644 --- a/packages/shared/src/lib/query.ts +++ b/packages/shared/src/lib/query.ts @@ -141,6 +141,7 @@ export enum RequestKey { Source = 'source', Sources = 'sources', OneSignal = 'onesignal', + ApplePush = 'apple_push', ActiveUsers = 'active_users', PushNotification = 'push_notification', ShortUrl = 'short_url', diff --git a/packages/webapp/hooks/useWebappVersion.ts b/packages/webapp/hooks/useWebappVersion.ts index 634a0b0145..3761631c79 100644 --- a/packages/webapp/hooks/useWebappVersion.ts +++ b/packages/webapp/hooks/useWebappVersion.ts @@ -3,6 +3,9 @@ import { useRouter } from 'next/router'; import type { ParsedUrlQuery } from 'querystring'; const getVersion = (query: ParsedUrlQuery): string | undefined => { + if (query.ios) { + return 'ios'; + } if (query.android) { return 'android'; } diff --git a/packages/webapp/pages/popup/notifications/enable.tsx b/packages/webapp/pages/popup/notifications/enable.tsx index 18d826a253..9da45d2fdc 100644 --- a/packages/webapp/pages/popup/notifications/enable.tsx +++ b/packages/webapp/pages/popup/notifications/enable.tsx @@ -39,8 +39,7 @@ const Description = classed('p', 'typo-callout text-text-tertiary'); function Enable(): React.ReactElement { const router = useRouter(); - const { isSubscribed, isInitialized, logPermissionGranted } = - usePushNotificationContext(); + const { isSubscribed, isInitialized } = usePushNotificationContext(); const { onEnablePush } = usePushNotificationMutation(); const { sendBeacon } = useLogContext(); const { source } = router.query; @@ -60,7 +59,6 @@ function Enable(): React.ReactElement { postWindowMessage(ENABLE_NOTIFICATION_WINDOW_KEY, { permission: 'granted', }); - logPermissionGranted(source as NotificationPromptSource); closeWindow(); return; }