Rewrite push registerer

This commit is contained in:
Alex Gleason 2024-10-15 15:59:53 -05:00
parent cbd964d7d4
commit 59b3df9641
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
8 changed files with 88 additions and 264 deletions

View File

@ -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<string>, value: any) =>
(dispatch: AppDispatch) => {
dispatch(setAlerts(path, value));
dispatch(saveSettings() as any);
};

View File

@ -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<WebPushSubscription | null> {
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<WebPushSubscription> {
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<PushSubscription> {
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<ServiceWorkerRegistration>(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<string, any> }) => {
// 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();
}

View File

@ -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<string>, value: any) =>
(dispatch: React.Dispatch<AnyAction>) =>
dispatch({
type: SET_ALERTS,
path,
value,
});
export {
SET_BROWSER_SUPPORT,
SET_SUBSCRIPTION,
CLEAR_SUBSCRIPTION,
SET_ALERTS,
setBrowserSupport,
setSubscription,
clearSubscription,
setAlerts,
};

View File

@ -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<IUI> = ({ children }) => {
const api = useApi();
const history = useHistory();
const dispatch = useAppDispatch();
const node = useRef<HTMLDivElement | null>(null);
@ -470,7 +471,7 @@ const UI: React.FC<IUI> = ({ children }) => {
useEffect(() => {
if (vapidKey) {
dispatch(registerPushNotifications(vapidKey));
registerPushNotifications(api, vapidKey).catch(console.warn);
}
}, [vapidKey]);

View File

@ -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,

View File

@ -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,
});
});
});

View File

@ -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<string, boolean>({
follow: true,
follow_request: true,
favourite: true,
reblog: true,
mention: true,
poll: true,
}),
isSubscribed: false,
browserSupport: false,
});
type Subscription = ReturnType<typeof SubscriptionRecord>;
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;
}
}

View File

@ -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(),
});