Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,39 @@ self.addEventListener('install', event => {
});
});

// Handle push events for notifications
self.addEventListener('push', event => {
const data = event.data ? event.data.json() : {};
const title =
data.title ||
`${service.charAt(0).toUpperCase() + service.slice(1)} News Update`;
const options = {
body: data.body || 'Check out the latest news',
icon: data.icon || `/images/icons/icon-192x192.png`,
badge: data.badge || `/images/icons/icon-96x96.png`,
data: {
url: data.url || `/${service}`,
},
tag: data.tag || 'news-notification',
// Ensure notification is shown on iOS
renotify: true,
};

event.waitUntil(self.registration.showNotification(title, options));
});

// Handle notification click
self.addEventListener('notificationclick', event => {
event.notification.close();
// Open the URL associated with the notification
event.waitUntil(clients.openWindow(event.notification.data.url));
});

// Handle notification close - no special action needed
self.addEventListener('notificationclose', event => {
// Notification was dismissed by the user
});

const CACHEABLE_FILES = [
// Reverb
/^https:\/\/static(?:\.test)?\.files\.bbci\.co\.uk\/ws\/(?:simorgh-assets|simorgh1-preview-assets|simorgh2-preview-assets)\/public\/static\/js\/reverb\/reverb-3.10.2.js$/,
Expand Down
32 changes: 32 additions & 0 deletions src/app/components/NotificationPermission/index.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { css } from '@emotion/react';

const styles = {
container: css({
display: 'flex',
justifyContent: 'center',
padding: '0.5rem 1rem',
backgroundColor: '#b80000', // BBC red
position: 'relative',
zIndex: 10,
}),
button: css({
backgroundColor: 'white',
color: '#222222',
border: 'none',
borderRadius: '0.25rem',
padding: '0.5rem 1rem',
fontSize: '0.875rem',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'background-color 0.2s',
'&:hover': {
backgroundColor: '#f2f2f2',
},
'&:focus': {
outline: 'none',
boxShadow: '0 0 0 0.25rem rgba(255, 255, 255, 0.3)',
},
}),
};

export default styles;
174 changes: 174 additions & 0 deletions src/app/components/NotificationPermission/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/** @jsx jsx */
/* @jsxFrag React.Fragment */
import { useState, useEffect, use } from 'react';
import { jsx } from '@emotion/react';
import useIsPWA from '../../hooks/useIsPWA';
import { RequestContext } from '../../contexts/RequestContext';
import styles from './index.styles';

const NotificationPermission = () => {
const [permissionState, setPermissionState] = useState<string>('default');
const [isSubscribed, setIsSubscribed] = useState<boolean>(false);
const [isMobilePlatform, setIsMobilePlatform] = useState<boolean>(false);
const isPWA = useIsPWA();
const { isAmp } = use(RequestContext);

// Detect if user is on Android or iOS
useEffect(() => {
if (typeof window !== 'undefined') {
const userAgent = window.navigator.userAgent.toLowerCase();
const isAndroid = /android/.test(userAgent);
const isIOS = /iphone|ipad|ipod/.test(userAgent);
setIsMobilePlatform(isAndroid || isIOS);
}
}, []);

// Helper function to convert base64 to Uint8Array for VAPID key
const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');

const rawData = window.atob(base64);
const outputArray = new Uint8Array(new ArrayBuffer(rawData.length));

for (let i = 0; i < rawData.length; i += 1) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
};

// Check if the user is already subscribed to push notifications
const checkSubscriptionStatus = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}

try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
setIsSubscribed(!!subscription);
} catch (error) {
// Silent fail - just means we can't check subscription status
}
};

// Check if notifications are supported and get current permission state
useEffect(() => {
if (typeof window !== 'undefined' && 'Notification' in window && !isAmp) {
setPermissionState(Notification.permission);
checkSubscriptionStatus();
}
}, [isAmp]);

// Helper function to send a test notification (for development only)
const sendTestNotification = () => {
if (!('Notification' in window)) return;

// Get the current service from the URL path
const service = window.location.pathname.split('/')[1] || 'news';

const notification = new Notification('Test Notification', {
body: 'This is a test notification from your PWA',
icon: `/${service}/images/icons/icon-192x192.png`,
});

notification.onclick = () => {
window.focus();
notification.close();
};
};

// Note: Server-side subscription handling is not implemented
// This would typically send the subscription to a backend service

// Register for push notifications
const registerPushSubscription = async () => {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return;
}

try {
const registration = await navigator.serviceWorker.ready;

// In a real implementation, you would fetch this from your server
// For now, we'll use a placeholder
const publicVapidKey =
'BLVYfB5S8-34JmFr9I2NQ2IUzGs6qRxFSQ-wgWS2_lmHkx1iQzCFLwOaOYpKBIxuIbQ_D1JkG0K9-ZKsBMzKBYs';

await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
publicVapidKey,
) as unknown as ArrayBuffer,
});

// Subscription successful
setIsSubscribed(true);

// Note: In a production app, you would send the subscription to your server
} catch (error) {
// Silent fail - push subscription failed
}
};

// Request notification permission and register for push
const requestPermission = async () => {
if (!('Notification' in window)) {
return;
}

try {
const permission = await Notification.requestPermission();
setPermissionState(permission);

if (permission === 'granted') {
await registerPushSubscription();

// Track last visit time for inactivity detection
localStorage.setItem('lastVisitTime', new Date().getTime().toString());
localStorage.setItem('notificationEnabled', 'true');

// For testing: Send a test notification immediately
if (process.env.NODE_ENV === 'development') {
sendTestNotification();
}
}
} catch (error) {
// Silent fail - user probably dismissed the permission prompt
}
};

// Only show the notification permission button if:
// 1. We're in a PWA on Android or iOS (not in regular browser)
// 2. Notifications are supported
// 3. Permission is not already granted
// 4. User is not already subscribed
// 5. Not in AMP mode
if (
!isPWA ||
!isMobilePlatform ||
isAmp ||
(permissionState === 'granted' && isSubscribed)
) {
return null;
}

return (
<div css={styles.container}>
{permissionState !== 'granted' && (
<button
type="button"
css={styles.button}
onClick={requestPermission}
aria-label="Enable notifications"
>
Enable notifications
</button>
)}
</div>
);
};

export default NotificationPermission;
13 changes: 12 additions & 1 deletion src/app/components/PageLayoutWrapper/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @jsx jsx */
/* @jsxFrag React.Fragment */

import React, { PropsWithChildren, use } from 'react';
import React, { PropsWithChildren, use, useEffect } from 'react';
import { jsx } from '@emotion/react';
import { Helmet } from 'react-helmet';
import GlobalStyles from '#psammead/psammead-styles/src/global-styles';
Expand All @@ -15,6 +15,7 @@ import ManifestContainer from '../../legacy/containers/Manifest';
import ServiceWorker from '../ServiceWorker';
import { ServiceContext } from '../../contexts/ServiceContext';
import { RequestContext } from '../../contexts/RequestContext';
import NotificationPermission from '../NotificationPermission';
import fontFacesLazy from '../ThemeProvider/fontFacesLazy';
import styles from './index.styles';
import { OptimoMostReadRecord, CPSMostReadRecord } from '../MostRead/types';
Expand Down Expand Up @@ -203,6 +204,15 @@ const PageLayoutWrapper = ({
localStorage.setItem(topicsStorageKey, JSON.stringify(topicsContents));
`;

// Track last visit time for inactivity detection
useEffect(() => {
if (typeof window !== 'undefined') {
// Record current visit time
const currentTime = new Date().getTime();
localStorage.setItem('lastVisitTime', currentTime.toString());
}
}, []);

return (
<>
<Helmet
Expand All @@ -218,6 +228,7 @@ const PageLayoutWrapper = ({
{!isErrorPage && <WebVitals pageType={pageType} />}
<GlobalStyles />
<div id="main-wrapper" css={styles.wrapper}>
<NotificationPermission />
<HeaderContainer
propsForTopBarOJComponent={{
blocks: pageData?.secondaryColumn?.topStories || [],
Expand Down
Loading