fix(app): improve service worker notification handling (#6)
This commit is contained in:
parent
4cd1caaa80
commit
5d5815963c
@ -48,6 +48,7 @@ const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({childre
|
|||||||
const [hasHandledNotificationNav, setHasHandledNotificationNav] = React.useState(false);
|
const [hasHandledNotificationNav, setHasHandledNotificationNav] = React.useState(false);
|
||||||
const [previousMobileLayoutState, setPreviousMobileLayoutState] = React.useState(mobileLayoutState.enabled);
|
const [previousMobileLayoutState, setPreviousMobileLayoutState] = React.useState(mobileLayoutState.enabled);
|
||||||
const lastMobileHistoryBuildRef = React.useRef<{ts: number; path: string} | null>(null);
|
const lastMobileHistoryBuildRef = React.useRef<{ts: number; path: string} | null>(null);
|
||||||
|
const lastNotificationNavRef = React.useRef<{ts: number; key: string} | null>(null);
|
||||||
const isLocationStoreHydrated = LocationStore.isHydrated;
|
const isLocationStoreHydrated = LocationStore.isHydrated;
|
||||||
const canNavigateToProtectedRoutes = InitializationStore.canNavigateToProtectedRoutes;
|
const canNavigateToProtectedRoutes = InitializationStore.canNavigateToProtectedRoutes;
|
||||||
const pendingRedirectRef = React.useRef<string | null>(null);
|
const pendingRedirectRef = React.useRef<string | null>(null);
|
||||||
@ -78,6 +79,18 @@ const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({childre
|
|||||||
const shouldBypassGateway = isAuthRoute && pathname !== Routes.PENDING_VERIFICATION;
|
const shouldBypassGateway = isAuthRoute && pathname !== Routes.PENDING_VERIFICATION;
|
||||||
const authToken = AuthenticationStore.authToken;
|
const authToken = AuthenticationStore.authToken;
|
||||||
|
|
||||||
|
const normalizeInternalUrl = React.useCallback((rawUrl: string): string => {
|
||||||
|
try {
|
||||||
|
const u = new URL(rawUrl, window.location.origin);
|
||||||
|
if (u.origin === window.location.origin) {
|
||||||
|
return u.pathname + u.search + u.hash;
|
||||||
|
}
|
||||||
|
return rawUrl;
|
||||||
|
} catch {
|
||||||
|
return rawUrl;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!SessionManager.isInitialized) return;
|
if (!SessionManager.isInitialized) return;
|
||||||
if (AccountManager.isSwitching) return;
|
if (AccountManager.isSwitching) return;
|
||||||
@ -242,16 +255,24 @@ const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({childre
|
|||||||
);
|
);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isAuthenticated || !mobileLayoutState.enabled) return;
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
const handleNotificationNavigate = (event: MessageEvent) => {
|
const handleNotificationNavigate = (event: MessageEvent) => {
|
||||||
if (event.data?.type === 'NOTIFICATION_CLICK_NAVIGATE') {
|
if (event.data?.type === 'NOTIFICATION_CLICK_NAVIGATE') {
|
||||||
if (hasHandledNotificationNav) {
|
const rawUrl = typeof event.data.url === 'string' ? event.data.url : null;
|
||||||
|
if (!rawUrl) return;
|
||||||
|
|
||||||
|
const targetUserId =
|
||||||
|
typeof event.data.targetUserId === 'string' ? (event.data.targetUserId as string) : undefined;
|
||||||
|
|
||||||
|
const normalizedUrl = normalizeInternalUrl(rawUrl);
|
||||||
|
const key = `${targetUserId ?? ''}:${normalizedUrl}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const last = lastNotificationNavRef.current;
|
||||||
|
if (last && last.key === key && now - last.ts < 1500) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
lastNotificationNavRef.current = {ts: now, key};
|
||||||
const url = event.data.url;
|
|
||||||
const targetUserId = event.data.targetUserId as string | undefined;
|
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (targetUserId && targetUserId !== AccountManager.currentUserId && AccountManager.canSwitchAccounts) {
|
if (targetUserId && targetUserId !== AccountManager.currentUserId && AccountManager.canSwitchAccounts) {
|
||||||
@ -262,7 +283,12 @@ const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({childre
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateWithHistoryStack(url);
|
if (mobileLayoutState.enabled) {
|
||||||
|
navigateWithHistoryStack(normalizedUrl);
|
||||||
|
} else {
|
||||||
|
RouterUtils.transitionTo(normalizedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
setHasHandledNotificationNav(true);
|
setHasHandledNotificationNav(true);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
@ -283,7 +309,12 @@ const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({childre
|
|||||||
newParams.delete('fromNotification');
|
newParams.delete('fromNotification');
|
||||||
const cleanPath = location.pathname + (newParams.toString() ? `?${newParams.toString()}` : '');
|
const cleanPath = location.pathname + (newParams.toString() ? `?${newParams.toString()}` : '');
|
||||||
|
|
||||||
navigateWithHistoryStack(cleanPath);
|
if (mobileLayoutState.enabled) {
|
||||||
|
navigateWithHistoryStack(cleanPath);
|
||||||
|
} else {
|
||||||
|
RouterUtils.transitionTo(cleanPath);
|
||||||
|
}
|
||||||
|
|
||||||
setHasHandledNotificationNav(true);
|
setHasHandledNotificationNav(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -293,7 +324,14 @@ const RootComponent: React.FC<{children?: React.ReactNode}> = observer(({childre
|
|||||||
return () => {
|
return () => {
|
||||||
navigator.serviceWorker?.removeEventListener('message', handleNotificationNavigate);
|
navigator.serviceWorker?.removeEventListener('message', handleNotificationNavigate);
|
||||||
};
|
};
|
||||||
}, [isAuthenticated, mobileLayoutState.enabled, hasHandledNotificationNav, location, navigateWithHistoryStack]);
|
}, [
|
||||||
|
isAuthenticated,
|
||||||
|
mobileLayoutState.enabled,
|
||||||
|
hasHandledNotificationNav,
|
||||||
|
location,
|
||||||
|
navigateWithHistoryStack,
|
||||||
|
normalizeInternalUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (currentUser?.pendingManualVerification) {
|
if (currentUser?.pendingManualVerification) {
|
||||||
|
|||||||
@ -130,12 +130,25 @@ const focusOrOpenClient = async (targetUrl: string, targetUserId?: string): Prom
|
|||||||
if (targetUserId) {
|
if (targetUserId) {
|
||||||
message.targetUserId = targetUserId;
|
message.targetUserId = targetUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientList = await postMessageToClients(message);
|
const clientList = await postMessageToClients(message);
|
||||||
for (const client of clientList) {
|
|
||||||
if (client.url === targetUrl) {
|
const exact = clientList.find((c) => c.url === targetUrl);
|
||||||
await client.focus();
|
if (exact) {
|
||||||
return;
|
await exact.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameOrigin = clientList.find((c) => {
|
||||||
|
try {
|
||||||
|
return new URL(c.url).origin === self.location.origin;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
if (sameOrigin) {
|
||||||
|
await sameOrigin.focus();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (self.clients.openWindow) {
|
if (self.clients.openWindow) {
|
||||||
|
|||||||
@ -123,6 +123,81 @@ export interface NotificationResult {
|
|||||||
nativeNotificationId: string | null;
|
nativeNotificationId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getServiceWorkerRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
|
||||||
|
if (typeof navigator === 'undefined' || typeof navigator.serviceWorker === 'undefined') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return (await navigator.serviceWorker.getRegistration()) ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryShowNotificationViaServiceWorker = async ({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
url,
|
||||||
|
icon,
|
||||||
|
targetUserId,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
url?: string;
|
||||||
|
icon?: string;
|
||||||
|
targetUserId?: string;
|
||||||
|
}): Promise<{shown: boolean; result: NotificationResult}> => {
|
||||||
|
const registration = await getServiceWorkerRegistration();
|
||||||
|
if (!registration) {
|
||||||
|
return {shown: false, result: {browserNotification: null, nativeNotificationId: null}};
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: NotificationOptions = {body};
|
||||||
|
|
||||||
|
if (icon) {
|
||||||
|
options.icon = icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url || targetUserId) {
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (url) data.url = url;
|
||||||
|
if (targetUserId) data.target_user_id = targetUserId;
|
||||||
|
options.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await registration.showNotification(title, options);
|
||||||
|
return {shown: true, result: {browserNotification: null, nativeNotificationId: null}};
|
||||||
|
} catch {
|
||||||
|
return {shown: false, result: {browserNotification: null, nativeNotificationId: null}};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tryShowNotificationViaWindowNotification = ({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
url,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
url?: string;
|
||||||
|
icon?: string;
|
||||||
|
}): NotificationResult => {
|
||||||
|
const notificationOptions: NotificationOptions = icon ? {body, icon} : {body};
|
||||||
|
const notification = new Notification(title, notificationOptions);
|
||||||
|
notification.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
window.focus();
|
||||||
|
if (url) {
|
||||||
|
RouterUtils.transitionTo(url);
|
||||||
|
}
|
||||||
|
notification.close();
|
||||||
|
});
|
||||||
|
return {browserNotification: notification, nativeNotificationId: null};
|
||||||
|
};
|
||||||
|
|
||||||
export const showNotification = async ({
|
export const showNotification = async ({
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
@ -136,40 +211,46 @@ export const showNotification = async ({
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
playSound?: boolean;
|
playSound?: boolean;
|
||||||
}): Promise<NotificationResult> => {
|
}): Promise<NotificationResult> => {
|
||||||
if (playSound) {
|
try {
|
||||||
playNotificationSoundIfEnabled();
|
if (playSound) {
|
||||||
}
|
playNotificationSoundIfEnabled();
|
||||||
|
|
||||||
const electronApi = getElectronAPI();
|
|
||||||
if (electronApi) {
|
|
||||||
try {
|
|
||||||
const result = await electronApi.showNotification({
|
|
||||||
title,
|
|
||||||
body,
|
|
||||||
icon: icon ?? '',
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
return {browserNotification: null, nativeNotificationId: result.id};
|
|
||||||
} catch {
|
|
||||||
return {browserNotification: null, nativeNotificationId: null};
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
const electronApi = getElectronAPI();
|
||||||
const notificationOptions: NotificationOptions = icon ? {body, icon} : {body};
|
if (electronApi) {
|
||||||
const notification = new Notification(title, notificationOptions);
|
try {
|
||||||
notification.addEventListener('click', (event) => {
|
const result = await electronApi.showNotification({
|
||||||
event.preventDefault();
|
title,
|
||||||
window.focus();
|
body,
|
||||||
if (url) {
|
icon: icon ?? '',
|
||||||
RouterUtils.transitionTo(url);
|
url,
|
||||||
|
});
|
||||||
|
return {browserNotification: null, nativeNotificationId: result.id};
|
||||||
|
} catch {
|
||||||
|
return {browserNotification: null, nativeNotificationId: null};
|
||||||
}
|
}
|
||||||
notification.close();
|
}
|
||||||
});
|
|
||||||
return {browserNotification: notification, nativeNotificationId: null};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {browserNotification: null, nativeNotificationId: null};
|
const targetUserId = AuthenticationStore.currentUserId ?? undefined;
|
||||||
|
|
||||||
|
const swAttempt = await tryShowNotificationViaServiceWorker({title, body, url, icon, targetUserId});
|
||||||
|
if (swAttempt.shown) {
|
||||||
|
return swAttempt.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof Notification !== 'undefined' && Notification.permission === 'granted') {
|
||||||
|
try {
|
||||||
|
return tryShowNotificationViaWindowNotification({title, body, url, icon});
|
||||||
|
} catch {
|
||||||
|
const swFallback = await tryShowNotificationViaServiceWorker({title, body, url, icon, targetUserId});
|
||||||
|
return swFallback.result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return swAttempt.result;
|
||||||
|
} catch {
|
||||||
|
return {browserNotification: null, nativeNotificationId: null};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const closeNativeNotification = (id: string): void => {
|
export const closeNativeNotification = (id: string): void => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user