diff --git a/src/actions/push-notifications/index.ts b/src/actions/push-notifications/index.ts deleted file mode 100644 index 69fdf2787..000000000 --- a/src/actions/push-notifications/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { register, saveSettings } from './registerer'; -import { - SET_BROWSER_SUPPORT, - SET_SUBSCRIPTION, - CLEAR_SUBSCRIPTION, - SET_ALERTS, - setAlerts, -} from './setter'; - -import type { AppDispatch } from 'soapbox/store'; - -export { - SET_BROWSER_SUPPORT, - SET_SUBSCRIPTION, - CLEAR_SUBSCRIPTION, - SET_ALERTS, - register, - changeAlerts, -}; - -const changeAlerts = (path: Array, value: any) => - (dispatch: AppDispatch) => { - dispatch(setAlerts(path, value)); - dispatch(saveSettings() as any); - }; diff --git a/src/actions/push-notifications/registerer.ts b/src/actions/push-notifications/registerer.ts index c521534c3..4a7df6a2b 100644 --- a/src/actions/push-notifications/registerer.ts +++ b/src/actions/push-notifications/registerer.ts @@ -1,157 +1,110 @@ -import { createPushSubscription, updatePushSubscription } from 'soapbox/actions/push-subscriptions'; -import { pushNotificationsSetting } from 'soapbox/settings'; +/* eslint-disable compat/compat */ +import { HTTPError } from 'soapbox/api/HTTPError'; +import { MastodonClient } from 'soapbox/api/MastodonClient'; +import { WebPushSubscription, webPushSubscriptionSchema } from 'soapbox/schemas/web-push'; import { decode as decodeBase64 } from 'soapbox/utils/base64'; -import { setBrowserSupport, setSubscription, clearSubscription } from './setter'; - -import type { AppDispatch, RootState } from 'soapbox/store'; -import type { Me } from 'soapbox/types/soapbox'; - -// Taken from https://www.npmjs.com/package/web-push -const urlBase64ToUint8Array = (base64String: string) => { +/** Taken from https://www.npmjs.com/package/web-push */ +function urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding) .replace(/-/g, '+') .replace(/_/g, '/'); return decodeBase64(base64); -}; +} -const getRegistration = () => { - if (navigator.serviceWorker) { - return navigator.serviceWorker.ready; - } else { - throw 'Your browser does not support Service Workers.'; +async function getBackendSubscription(api: MastodonClient): Promise { + try { + const response = await api.get('/api/v1/push/subscription'); + const data = await response.json(); + return webPushSubscriptionSchema.parse(data); + } catch (e) { + if (e instanceof HTTPError && e.response.status === 404) { + return null; + } else { + throw e; + } } -}; +} -const getPushSubscription = (registration: ServiceWorkerRegistration) => - registration.pushManager.getSubscription() - .then(subscription => ({ registration, subscription })); +async function sendSubscriptionToBackend(api: MastodonClient, subscription: PushSubscription): Promise { + const params = { + subscription: subscription.toJSON(), + }; -const subscribe = (vapidKey: string, registration: ServiceWorkerRegistration) => - registration.pushManager.subscribe({ + const response = await api.post('/api/v1/push/subscription', params); + const data = await response.json(); + + return webPushSubscriptionSchema.parse(data); +} + +async function createSubscription(vapidKey: string): Promise { + const registration = await navigator.serviceWorker.ready; + + return registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidKey), }); +} -const unsubscribe = ({ registration, subscription }: { - registration: ServiceWorkerRegistration; - subscription: PushSubscription | null; -}) => - subscription ? subscription.unsubscribe().then(() => registration) : new Promise(r => r(registration)); +async function getOrCreateSubscription(vapidKey: string): Promise<{ subscription: PushSubscription; created: boolean }> { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); -const sendSubscriptionToBackend = (subscription: PushSubscription, me: Me) => - (dispatch: AppDispatch, getState: () => RootState) => { - const alerts = getState().push_notifications.alerts.toJS(); - const params = { subscription: subscription.toJSON(), data: { alerts } }; - - if (me) { - const data = pushNotificationsSetting.get(me); - if (data) { - params.data = data; - } - } - - return dispatch(createPushSubscription(params)); - }; + if (subscription) { + return { subscription, created: false }; + } else { + const subscription = await createSubscription(vapidKey); + return { subscription, created: true }; + } +} // Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload -// eslint-disable-next-line compat/compat const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype); -const register = (vapidKey: string) => - (dispatch: AppDispatch, getState: () => RootState) => { - const me = getState().me; +export async function registerPushNotifications(api: MastodonClient, vapidKey: string) { + if (!supportsPushNotifications) { + console.warn('Your browser does not support Web Push Notifications.'); + return; + } - dispatch(setBrowserSupport(supportsPushNotifications)); + const { subscription, created } = await getOrCreateSubscription(vapidKey); - if (!supportsPushNotifications) { - console.warn('Your browser does not support Web Push Notifications.'); - return; - } + if (created) { + await sendSubscriptionToBackend(api, subscription); + return; + } - if (!vapidKey) { - console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.'); - return; - } + // We have a subscription, check if it is still valid. + const backend = await getBackendSubscription(api); - getRegistration() - .then(getPushSubscription) - // @ts-ignore - .then(({ registration, subscription }: { - registration: ServiceWorkerRegistration; - subscription: PushSubscription | null; - }) => { - if (subscription !== null) { - // We have a subscription, check if it is still valid - const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey!)).toString(); - const subscriptionServerKey = urlBase64ToUint8Array(vapidKey).toString(); - const serverEndpoint = getState().push_notifications.subscription?.endpoint; + // If the VAPID public key did not change and the endpoint corresponds + // to the endpoint saved in the backend, the subscription is valid. + if (backend && subscriptionMatchesBackend(subscription, backend)) { + return; + } else { + // Something went wrong, try to subscribe again. + await subscription.unsubscribe(); + const newSubscription = await createSubscription(vapidKey); + await sendSubscriptionToBackend(api, newSubscription); + } +} - // If the VAPID public key did not change and the endpoint corresponds - // to the endpoint saved in the backend, the subscription is valid - if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) { - return { subscription }; - } else { - // Something went wrong, try to subscribe again - return unsubscribe({ registration, subscription }).then((registration: ServiceWorkerRegistration) => { - return subscribe(vapidKey, registration); - }).then( - (subscription: PushSubscription) => dispatch(sendSubscriptionToBackend(subscription, me) as any)); - } - } +/** Check if the VAPID key and endpoint of the subscription match the data in the backend. */ +function subscriptionMatchesBackend(subscription: PushSubscription, backend: WebPushSubscription): boolean { + const { applicationServerKey } = subscription.options; - // No subscription, try to subscribe - return subscribe(vapidKey, registration) - .then(subscription => dispatch(sendSubscriptionToBackend(subscription, me) as any)); - }) - .then(({ subscription }: { subscription: PushSubscription | Record }) => { - // If we got a PushSubscription (and not a subscription object from the backend) - // it means that the backend subscription is valid (and was set during hydration) - if (!(subscription instanceof PushSubscription)) { - dispatch(setSubscription(subscription as PushSubscription)); - if (me) { - pushNotificationsSetting.set(me, { alerts: subscription.alerts }); - } - } - }) - .catch(error => { - if (error.code === 20 && error.name === 'AbortError') { - console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.'); - } else if (error.code === 5 && error.name === 'InvalidCharacterError') { - console.error('The VAPID public key seems to be invalid:', vapidKey); - } + if (subscription.endpoint !== backend.endpoint) { + return false; + } - // Clear alerts and hide UI settings - dispatch(clearSubscription()); + if (!applicationServerKey) { + return false; + } - if (me) { - pushNotificationsSetting.remove(me); - } + const backendKeyBytes: Uint8Array = urlBase64ToUint8Array(backend.server_key); + const subscriptionKeyBytes: Uint8Array = new Uint8Array(applicationServerKey); - return getRegistration() - .then(getPushSubscription) - .then(unsubscribe); - }) - .catch(console.warn); - }; - -const saveSettings = () => - (dispatch: AppDispatch, getState: () => RootState) => { - const state = getState().push_notifications; - const alerts = state.alerts; - const data = { alerts }; - const me = getState().me; - - return dispatch(updatePushSubscription({ data })).then(() => { - if (me) { - pushNotificationsSetting.set(me, data); - } - }).catch(console.warn); - }; - -export { - register, - saveSettings, -}; + return backendKeyBytes.toString() === subscriptionKeyBytes.toString(); +} \ No newline at end of file diff --git a/src/actions/push-notifications/setter.ts b/src/actions/push-notifications/setter.ts deleted file mode 100644 index 739427e7e..000000000 --- a/src/actions/push-notifications/setter.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { AnyAction } from 'redux'; - -const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT'; -const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION'; -const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION'; -const SET_ALERTS = 'PUSH_NOTIFICATIONS_SET_ALERTS'; - -const setBrowserSupport = (value: boolean) => ({ - type: SET_BROWSER_SUPPORT, - value, -}); - -const setSubscription = (subscription: PushSubscription) => ({ - type: SET_SUBSCRIPTION, - subscription, -}); - -const clearSubscription = () => ({ - type: CLEAR_SUBSCRIPTION, -}); - -const setAlerts = (path: Array, value: any) => - (dispatch: React.Dispatch) => - dispatch({ - type: SET_ALERTS, - path, - value, - }); - -export { - SET_BROWSER_SUPPORT, - SET_SUBSCRIPTION, - CLEAR_SUBSCRIPTION, - SET_ALERTS, - setBrowserSupport, - setSubscription, - clearSubscription, - setAlerts, -}; diff --git a/src/features/ui/index.tsx b/src/features/ui/index.tsx index ca65bc492..38a06855b 100644 --- a/src/features/ui/index.tsx +++ b/src/features/ui/index.tsx @@ -8,7 +8,7 @@ import { fetchCustomEmojis } from 'soapbox/actions/custom-emojis'; import { fetchFilters } from 'soapbox/actions/filters'; import { fetchMarker } from 'soapbox/actions/markers'; import { expandNotifications } from 'soapbox/actions/notifications'; -import { register as registerPushNotifications } from 'soapbox/actions/push-notifications'; +import { registerPushNotifications } from 'soapbox/actions/push-notifications/registerer'; import { fetchScheduledStatuses } from 'soapbox/actions/scheduled-statuses'; import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions'; import { expandHomeTimeline } from 'soapbox/actions/timelines'; @@ -16,7 +16,7 @@ import { useUserStream } from 'soapbox/api/hooks'; import SidebarNavigation from 'soapbox/components/sidebar-navigation'; import ThumbNavigation from 'soapbox/components/thumb-navigation'; import { Layout } from 'soapbox/components/ui'; -import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures, useDraggedFiles, useInstance, useLoggedIn } from 'soapbox/hooks'; +import { useAppDispatch, useAppSelector, useOwnAccount, useSoapboxConfig, useFeatures, useDraggedFiles, useInstance, useLoggedIn, useApi } from 'soapbox/hooks'; import AdminPage from 'soapbox/pages/admin-page'; import ChatsPage from 'soapbox/pages/chats-page'; import DefaultPage from 'soapbox/pages/default-page'; @@ -380,6 +380,7 @@ interface IUI { } const UI: React.FC = ({ children }) => { + const api = useApi(); const history = useHistory(); const dispatch = useAppDispatch(); const node = useRef(null); @@ -470,7 +471,7 @@ const UI: React.FC = ({ children }) => { useEffect(() => { if (vapidKey) { - dispatch(registerPushNotifications(vapidKey)); + registerPushNotifications(api, vapidKey).catch(console.warn); } }, [vapidKey]); diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 4534812dd..925e40d8a 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -39,7 +39,6 @@ import patron from './patron'; import pending_statuses from './pending-statuses'; import polls from './polls'; import profile_hover_card from './profile-hover-card'; -import push_notifications from './push-notifications'; import relationships from './relationships'; import reports from './reports'; import scheduled_statuses from './scheduled-statuses'; @@ -97,7 +96,6 @@ const reducers = { pending_statuses, polls, profile_hover_card, - push_notifications, relationships, reports, scheduled_statuses, diff --git a/src/reducers/push-notifications.test.ts b/src/reducers/push-notifications.test.ts deleted file mode 100644 index 021cb1771..000000000 --- a/src/reducers/push-notifications.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import reducer from './push-notifications'; - -describe('push_notifications reducer', () => { - it('should return the initial state', () => { - expect(reducer(undefined, {} as any).toJS()).toEqual({ - subscription: null, - alerts: { - follow: true, - follow_request: true, - favourite: true, - reblog: true, - mention: true, - poll: true, - }, - isSubscribed: false, - browserSupport: false, - }); - }); -}); diff --git a/src/reducers/push-notifications.ts b/src/reducers/push-notifications.ts deleted file mode 100644 index a2b8ca51f..000000000 --- a/src/reducers/push-notifications.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Map as ImmutableMap, Record as ImmutableRecord } from 'immutable'; - -import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, SET_ALERTS } from '../actions/push-notifications'; - -import type { AnyAction } from 'redux'; - -const SubscriptionRecord = ImmutableRecord({ - id: '', - endpoint: '', -}); - -const ReducerRecord = ImmutableRecord({ - subscription: null as Subscription | null, - alerts: ImmutableMap({ - follow: true, - follow_request: true, - favourite: true, - reblog: true, - mention: true, - poll: true, - }), - isSubscribed: false, - browserSupport: false, -}); - -type Subscription = ReturnType; - -export default function push_subscriptions(state = ReducerRecord(), action: AnyAction) { - switch (action.type) { - case SET_SUBSCRIPTION: - return state - .set('subscription', SubscriptionRecord({ - id: action.subscription.id, - endpoint: action.subscription.endpoint, - })) - .set('alerts', ImmutableMap(action.subscription.alerts)) - .set('isSubscribed', true); - case SET_BROWSER_SUPPORT: - return state.set('browserSupport', action.value); - case CLEAR_SUBSCRIPTION: - return ReducerRecord(); - case SET_ALERTS: - return state.setIn(action.path, action.value); - default: - return state; - } -} diff --git a/src/schemas/web-push.ts b/src/schemas/web-push.ts index 9162f2bfb..f0553dc32 100644 --- a/src/schemas/web-push.ts +++ b/src/schemas/web-push.ts @@ -1,10 +1,12 @@ import { z } from 'zod'; +import { coerceObject } from './utils'; + /** https://docs.joinmastodon.org/entities/WebPushSubscription/ */ const webPushSubscriptionSchema = z.object({ id: z.coerce.string(), endpoint: z.string().url(), - alerts: z.object({ + alerts: coerceObject({ mention: z.boolean().optional(), status: z.boolean().optional(), reblog: z.boolean().optional(), @@ -15,7 +17,7 @@ const webPushSubscriptionSchema = z.object({ update: z.boolean().optional(), 'admin.sign_up': z.boolean().optional(), 'admin.report': z.boolean().optional(), - }).optional(), + }), server_key: z.string(), });