Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: native ios support #4049

Merged
merged 14 commits into from
Jan 16, 2025
251 changes: 182 additions & 69 deletions packages/shared/src/contexts/PushNotificationContext.tsx
Original file line number Diff line number Diff line change
@@ -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';
Copy link
Member

@sshanzel sshanzel Jan 17, 2025

Choose a reason for hiding this comment

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

Would this not import the OneSignal as a whole all the time now?

Copy link
Member Author

Choose a reason for hiding this comment

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

Good catch! Totally missed that

Copy link
Member Author

Choose a reason for hiding this comment

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

Fixed in prod

import type { NotificationPromptSource } from '../lib/log';
import { LogEvent } from '../lib/log';
import { checkIsExtension, disabledRefetch } from '../lib/func';
Expand All @@ -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<boolean>;
unsubscribe: (source: NotificationPromptSource) => Promise<void>;
}

export const PushNotificationsContext =
Expand All @@ -35,54 +34,30 @@ 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 {
children: ReactElement;
}

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<string>();
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>(subscriptionCallback);
subscriptionCallbackRef.current = subscriptionCallback;
const isEnabled = !!user && !isTesting && !isExtension;
const sourceRef = useRef<string>();
const subscriptionCallbackRef = useRef<SubscriptionCallback>();

const isEnabled = !!user && !isTesting;

const key = generateQueryKey(RequestKey.OneSignal, user);
const client = useQueryClient();
const {
Expand All @@ -100,75 +75,213 @@ 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 (
<PushNotificationsContext.Provider
value={{
isInitialized: !isEnabled || isFetched || !isSuccess,
isLoading,
isSubscribed,
isPushSupported: !!(isPushSupported && isSuccess && isEnabled),
onSourceChange,
logPermissionGranted,
shouldOpenPopup: false,
OneSignal: isEnabled && isFetched ? OneSignalCache : null,
isPushSupported: isPushSupported && isSuccess && isEnabled,
shouldOpenPopup: () => {
const { permission } = globalThis.Notification ?? {};
return permission === 'denied';
},
subscribe,
unsubscribe,
}}
>
{children}
</PushNotificationsContext.Provider>
);
}

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<void>({
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<boolean>((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 (
<PushNotificationsContext.Provider
value={{
isInitialized: isFetched || !isSuccess,
isLoading,
isSubscribed,
isPushSupported: true,
shouldOpenPopup: () => false,
subscribe,
unsubscribe,
}}
>
{children}
</PushNotificationsContext.Provider>
);
}

/**
* 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 <NativeAppleSubProvider>{children}</NativeAppleSubProvider>;
}

return <OneSignalSubProvider>{children}</OneSignalSubProvider>;
}

export const usePushNotificationContext = (): PushNotificationsContextData =>
useContext(PushNotificationsContext);
Loading
Loading