├── .eslintrc.json ├── .gitignore ├── README.md ├── globals.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── arrow-down.svg ├── arrow-up.svg ├── bell.png ├── bell2.png ├── clipboard.svg ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── ios_step_1.jpg ├── ios_step_2.jpg ├── ios_step_3.jpg ├── ios_step_4.jpg ├── logo.svg ├── manifest.json ├── mstile-144x144.png ├── mstile-150x150.png ├── mstile-310x150.png ├── mstile-310x310.png ├── mstile-70x70.png ├── next.svg ├── rocket.svg ├── sharing-image.png ├── sw.js └── vercel.svg ├── src ├── components │ ├── button.tsx │ ├── content-wrapper.tsx │ ├── disclaimer.tsx │ ├── error-diagnostics.tsx │ ├── footer.tsx │ ├── info.tsx │ ├── ios-instructional-static.tsx │ ├── ios-instructional-video.tsx │ ├── links.tsx │ ├── post-subscribe-actions.tsx │ ├── seo-text.tsx │ ├── subscriber.tsx │ ├── topic-subscriber-wrapper.tsx │ └── topic-subscriber.tsx ├── constants │ └── topics.ts ├── hooks │ └── useDeviceInfo.tsx ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ ├── cron │ │ │ ├── hn_top_new.ts │ │ │ └── hn_top_story.ts │ │ ├── hn_random.ts │ │ ├── hn_top_new.ts │ │ ├── hn_top_story.ts │ │ └── welcome.ts │ └── index.tsx ├── services │ ├── magicBell.ts │ └── subscriptionManager.ts ├── styles │ └── globals.css └── utils │ └── minVersionCheck.ts ├── tailwind.config.js ├── tsconfig.json └── youtube.html /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "react/no-unescaped-entities": "off", 5 | "@next/next/no-page-custom-font": "off" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | vercel.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About this project 2 | 3 | Apple released [beta](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados) support for Web Push notifications on iOS in February 2023, and made it official with the release of iOS 16.5 in [May 2023](https://www.macrumors.com/2023/05/09/apple-confirms-ios-16-5-release-date/). 4 | 5 | This project, hosted at https://webpushtest.com, showcases these new capabilities. In addition to iOS, it can also send Web Push notifications on desktop and Android (although this is somewhat old news). 6 | 7 | It is targeted at the end user, for whom installing a PWA and receiving a Web Push notification will likely be a new experience. As such, we have tried to include instructional information and device-specific error messages where relevant. Take a look at this Youtube short 8 | 9 | [![Demo of WebPushTest.com on Youtube](https://img.youtube.com/vi/aIlGLE_adzc/maxresdefault.jpg)](https://www.youtube.com/watch?v=aIlGLE_adzc) 10 | 11 | You can use the code for providing push notification support in your web/PWA apps, but you will need an API key from [MagicBell](https://www.magicbell.com). We offer a generous free tier so you can get started quickly. Apart from web-push, we offer a real-time in-app notification inbox you can add to your app in minutes. 12 | 13 | Relevant links: 14 | 15 | - [iOS now supports push notifications (and why you should care)](https://www.magicbell.com/blog/ios-now-supports-web-push-notifications-and-why-you-should-care) 16 | - [Twitter thread](https://twitter.com/Matt0xley/status/1668912123702030336) 17 | - If you'd like help, please start a [GitHub Discussion|(https://github.com/orgs/magicbell/discussions). 18 | 19 | ## Running locally 20 | 21 | First, install dependencies: 22 | 23 | ```bash 24 | npm install 25 | ``` 26 | 27 | Then, assuming you have a MagicBell account and have created a new project, obtain the NEXT_PUBLIC_MAGICBELL_API_KEY and MAGICBELL_API_SECRET from the [MagicBell](https://www.magicbell.com/) dashboard and set them as environment variables in a `.env` file at the root of this project: 28 | 29 | ```bash 30 | NEXT_PUBLIC_MAGICBELL_API_KEY=... 31 | MAGICBELL_API_SECRET=... 32 | ``` 33 | 34 | Then, start the development server: 35 | 36 | ```bash 37 | npm run dev 38 | ``` 39 | 40 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 41 | 42 | To observe iOS push notifications from your local development environment, you will need to expose your local server to the internet. We recommend using [ngrok](https://ngrok.com/): 43 | 44 | ```bash 45 | ngrok http 3000 46 | ``` 47 | 48 | After visiting the resulting public url on your device, be sure to also install the app as a PWA, using the "Add to Home Screen" option in the Safari share menu. 49 | 50 | ## Important files 51 | 52 | These will be the most relevant files to look at if you want to understand how this project works: 53 | 54 | - https://github.com/magicbell/webpush-test/blob/main/src/components/subscriber.tsx 55 | - https://github.com/magicbell/webpush-test/blob/main/src/services/subscriptionManager.ts 56 | - https://github.com/magicbell/webpush-test/blob/main/public/manifest.json 57 | - https://github.com/magicbell/webpush-test/blob/main/public/sw.js 58 | -------------------------------------------------------------------------------- /globals.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | NEXT_PUBLIC_MAGICBELL_API_KEY: string 5 | MAGICBELL_API_SECRET: string 6 | } 7 | } 8 | } 9 | 10 | export {} 11 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | typescript: { 5 | // !! WARN !! 6 | // Dangerously allow production builds to successfully complete even if 7 | // your project has type errors. 8 | // !! WARN !! 9 | ignoreBuildErrors: true, 10 | }, 11 | } 12 | 13 | module.exports = nextConfig 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "push-test", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@cloudflare/stream-react": "^1.8.0", 13 | "@magicbell/magicbell-react": "^10.7.3", 14 | "@magicbell/react-headless": "^4.2.7", 15 | "@magicbell/webpush": "^1.2.0", 16 | "@radix-ui/react-collapsible": "^1.0.3", 17 | "@radix-ui/react-toast": "^1.1.4", 18 | "@storybook/react": "^7.0.18", 19 | "@types/node": "20.2.5", 20 | "@types/react": "18.2.8", 21 | "@types/react-dom": "18.2.4", 22 | "@vercel/analytics": "^1.0.1", 23 | "autoprefixer": "10.4.14", 24 | "detectincognitojs": "^1.3.0", 25 | "eslint": "8.42.0", 26 | "eslint-config-next": "13.4.4", 27 | "firebase": "^9.22.2", 28 | "magicbell": "^1.7.2", 29 | "next": "13.4.4", 30 | "postcss": "8.4.24", 31 | "react": "18.2.0", 32 | "react-device-detect": "^2.2.3", 33 | "react-dom": "18.2.0", 34 | "tailwindcss": "3.3.2", 35 | "typescript": "5.1.3", 36 | "uuid": "^9.0.0" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /public/arrow-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /public/bell.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/bell.png -------------------------------------------------------------------------------- /public/bell2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/bell2.png -------------------------------------------------------------------------------- /public/clipboard.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/favicon.ico -------------------------------------------------------------------------------- /public/ios_step_1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/ios_step_1.jpg -------------------------------------------------------------------------------- /public/ios_step_2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/ios_step_2.jpg -------------------------------------------------------------------------------- /public/ios_step_3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/ios_step_3.jpg -------------------------------------------------------------------------------- /public/ios_step_4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/ios_step_4.jpg -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 7 | 8 | 13 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Web Push Test", 3 | "short_name": "WebPushTest", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "background_color": "#90cdf4", 7 | "theme_color": "#90cdf4", 8 | "orientation": "portrait-primary", 9 | "icons": [ 10 | { 11 | "src": "bell2.png", 12 | "sizes": "500x500", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "bell.png", 17 | "sizes": "192x192", 18 | "type": "image/png" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /public/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/mstile-144x144.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/mstile-310x150.png -------------------------------------------------------------------------------- /public/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/mstile-310x310.png -------------------------------------------------------------------------------- /public/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/mstile-70x70.png -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/rocket.svg: -------------------------------------------------------------------------------- 1 | 8 | 14 | -------------------------------------------------------------------------------- /public/sharing-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magicbell/webpush-ios-template/f0e73e33d89b4e8bcf71a2727bc829c26e1bafc5/public/sharing-image.png -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- 1 | importScripts("https://assets.magicbell.io/web-push-notifications/sw.js") 2 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/button.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import Image from "next/image" 3 | 4 | export default function Button(props: { 5 | text: string 6 | classname: string 7 | disabled: boolean 8 | onClick?: () => void 9 | loading?: true 10 | }) { 11 | const [hovered, setHovered] = useState(false) 12 | return ( 13 | <> 14 | rocket 25 | 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/components/content-wrapper.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image" 2 | import React from "react" 3 | import Info from "./info" 4 | import useDeviceInfo, { DeviceInfo } from "@/hooks/useDeviceInfo" 5 | 6 | export default function ContentWrapper(props: { 7 | children: React.ReactNode 8 | message: string 9 | }) { 10 | return ( 11 |
12 |

{props.message}

13 | {props.children} 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/components/disclaimer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Image from "next/image" 3 | 4 | export const magicBellHandle = "magicbell_io" 5 | 6 | export default function Disclaimer() { 7 | return ( 8 |
9 | 14 | built by 15 | magic bell logo 21 | 22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/components/error-diagnostics.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react" 2 | import va from "@vercel/analytics" 3 | 4 | import IosInstructionalVideo from "./ios-instructional-video" 5 | import useDeviceInfo, { DeviceInfo } from "@/hooks/useDeviceInfo" 6 | import { clientSettings } from "@magicbell/react-headless" 7 | import minVersionCheck from "@/utils/minVersionCheck" 8 | 9 | /** 10 | * Here we show the user some diagnostics to help them troubleshoot 11 | */ 12 | 13 | export default function ErrorDiagnostics(props: { error: string }) { 14 | const info = useDeviceInfo() 15 | useEffect(() => { 16 | va.track("error", { 17 | ...info, 18 | error: props.error, 19 | id: clientSettings.getState().userExternalId as string, // TODO: fix typing here 20 | }) 21 | }, [info, props.error]) 22 | function getContent() { 23 | if (!info) { 24 | return null 25 | } 26 | switch (info.deviceType) { 27 | case "browser": 28 | switch (info.notificationApiPermissionStatus) { 29 | case "denied": { 30 | if (info.isPrivate) { 31 | return ( 32 |

33 | {`It looks like you are browsing in private mode in ${info.browserName}. Unfortunately, this mode does not support notifications. Please try again in a non-private window.`} 34 |

35 | ) 36 | } 37 | return ( 38 |

39 | {`It looks like you denied notification permissions for WebPushTest.com in ${info.browserName}. To receive push notifications, please permit us to notify you.`} 40 |

41 | ) 42 | } 43 | } 44 | case "mobile": { 45 | switch (info.osName) { 46 | case "iOS": 47 | switch (info.standalone) { 48 | case false: 49 | return ( 50 |
51 | {`It looks like you have not yet installed this app on your device. Please install it using the instructions below, and try again.`} 52 | 53 |
54 | ) 55 | } 56 | if (!minVersionCheck(info.osVersion.toString(), 16, 5)) { 57 | return ( 58 |

59 | {`It looks like you are using iOS ${info.osVersion}. This demo requires iOS 16.5 or later.`} 60 |

61 | ) 62 | } 63 | } 64 | switch (info.notificationApiPermissionStatus) { 65 | case "denied": 66 | return ( 67 |

68 | {`It looks like you denied notification permissions for WebPushTest.com in ${info.browserName}. To receive push notifications, please permit us to notify you.`} 69 |

70 | ) 71 | } 72 | } 73 | } 74 | return null 75 | } 76 | return
{getContent()}
77 | } 78 | -------------------------------------------------------------------------------- /src/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import useDeviceInfo from "@/hooks/useDeviceInfo" 2 | import React, { useState } from "react" 3 | import Info from "./info" 4 | import * as Collapsible from "@radix-ui/react-collapsible" 5 | import Image from "next/image" 6 | 7 | export default function Footer(props: { 8 | open: boolean 9 | setOpen: React.Dispatch> 10 | }) { 11 | const info = useDeviceInfo() 12 | 13 | return ( 14 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/components/info.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from "react" 2 | import { DeviceInfo } from "@/hooks/useDeviceInfo" 3 | import * as Toast from "@radix-ui/react-toast" 4 | 5 | // This component shows PWA-push related information to the user 6 | export default function Info({ info }: { info: DeviceInfo }) { 7 | const infoRef = useRef(null) 8 | const [toastOpen, setToastOpen] = useState(false) 9 | const timerRef = useRef(0) 10 | 11 | React.useEffect(() => { 12 | return () => clearTimeout(timerRef.current) 13 | }, []) 14 | 15 | return ( 16 | 17 | 45 | 50 | 51 | Device details copied to clipboard 52 | 53 | 54 | 55 | 56 |

57 | 64 | 70 | 71 | Click anywhere in panel to copy details to clipboard 72 |

73 |
74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /src/components/ios-instructional-static.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | function Tile(props: { 4 | index: number; 5 | caption: string; 6 | captionHeight?: number; 7 | }) { 8 | return ( 9 |
13 |
16 | {`Step ${props.index}:`} 17 | {props.caption} 18 |
19 | {`ios 26 |
27 | ); 28 | } 29 | 30 | export default function IosInstructionalStatic() { 31 | return ( 32 | <> 33 |

37 | Installation instructions 38 |

39 |
40 | 41 | 42 | 43 | 44 |
45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/components/ios-instructional-video.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Stream } from "@cloudflare/stream-react" 3 | 4 | const width = 260 5 | 6 | type Props = 7 | | { 8 | withCaption: true 9 | captionText?: string 10 | } 11 | | { 12 | withCaption: false 13 | } 14 | 15 | export default function IosInstructionalVideo(props: Props) { 16 | return ( 17 |
18 | { 19 |
20 | {props.withCaption 21 | ? props.captionText || 22 | `If running this demo on iOS, ensure you are using 16.5 or later, and 23 | have this PWA "installed"; with Safari (installation 24 | instructions below` 25 | : null} 26 |
27 | } 28 | 29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/components/links.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | export default function Links() { 4 | return ( 5 |
11 | 16 | 22 | 28 | 29 | View source on Github 30 | 31 | 36 | 43 | 49 | 50 | A thread about web-push on iOS 16.5 51 | 52 |
53 | ) 54 | } 55 | -------------------------------------------------------------------------------- /src/components/post-subscribe-actions.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import Button from "./button" 3 | import TopicSubscriberWrapper from "./topic-subscriber-wrapper" 4 | import { topics } from "@/constants/topics" 5 | import magicBell from "@/services/magicBell" 6 | 7 | /** 8 | * This component shows once the user has successfully subscribed to Webpush. 9 | * Here they have the option to send more test notifications, or subscribe to granular topics. 10 | */ 11 | 12 | interface IProps { 13 | interactive: boolean 14 | onAfterInteract: () => void 15 | onError: (message: string) => void 16 | } 17 | 18 | export default function PostSubscribeActions(props: IProps) { 19 | const handleResend = async () => { 20 | try { 21 | await magicBell.sendNotification("hn_random") 22 | } catch (error: any) { 23 | props.onError(error.message) 24 | } 25 | props.onAfterInteract() 26 | } 27 | 28 | return ( 29 | <> 30 | {props.interactive ? ( 31 | 79 | 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /src/constants/topics.ts: -------------------------------------------------------------------------------- 1 | export const topics = { 2 | "HN Top Story": { id: "HN Top Story", name: "top HN stories" }, 3 | "HN Top New": { id: "HN Top New", name: "newest trending submissions" }, 4 | } as const 5 | -------------------------------------------------------------------------------- /src/hooks/useDeviceInfo.tsx: -------------------------------------------------------------------------------- 1 | import subscriptionManager from "@/services/subscriptionManager" 2 | import { createContext, useContext, useEffect, useState } from "react" 3 | import { clientSettings } from "@magicbell/react-headless" 4 | import { browserName, deviceType, osName, osVersion } from "react-device-detect" 5 | import { detectIncognito } from "detectincognitojs" 6 | import magicBell from "@/services/magicBell" 7 | 8 | // contains all relevant info about the device, for troubleshooting Notifications 9 | export type DeviceInfo = { 10 | standalone: boolean 11 | browserName: string 12 | osName: string 13 | deviceType: string 14 | isPrivate: boolean 15 | osVersion: string 16 | notificationApiPermissionStatus: string 17 | serviceWorkerStatus: string 18 | subscriptionState: "pending" | "subscribed" | "unsubscribed" 19 | topics: string[] 20 | } 21 | 22 | const DeviceInfoContext = createContext(null) 23 | 24 | export function DeviceInfoProvider(props: { children: React.ReactNode }) { 25 | const [info, setInfo] = useState(null) 26 | useEffect(() => { 27 | // initialize device info 28 | setInfo({ 29 | standalone: window.matchMedia("(display-mode: standalone)").matches, // true if PWA is installed 30 | browserName, 31 | osName, 32 | deviceType, 33 | osVersion, 34 | isPrivate: false, 35 | // note that user may still not have granted notification permissions on a system settings level 36 | notificationApiPermissionStatus: 37 | typeof Notification !== "undefined" 38 | ? Notification.permission 39 | : "Notification API unsupported", 40 | serviceWorkerStatus: "fetching", 41 | subscriptionState: "pending", 42 | topics: [], 43 | }) 44 | 45 | return subscriptionManager.subscribeToActiveSubscriptionFromLocalStorage( 46 | clientSettings.getState().userExternalId as string, // TODO: fix typing here 47 | async (activeSubscription, context) => { 48 | const { isPrivate } = await detectIncognito() 49 | const topics = await magicBell.getTopics() 50 | setInfo((info) => 51 | info 52 | ? { 53 | ...info, 54 | isPrivate, 55 | serviceWorkerStatus: 56 | context.serviceWorkerRegistration?.active?.state || 57 | "inactive", 58 | subscriptionState: Boolean(activeSubscription) 59 | ? "subscribed" 60 | : "unsubscribed", 61 | topics, 62 | } 63 | : null 64 | ) 65 | } 66 | ) 67 | }, []) 68 | 69 | return ( 70 | 71 | {props.children} 72 | 73 | ) 74 | } 75 | 76 | export default function useDeviceInfo() { 77 | return useContext(DeviceInfoContext) 78 | } 79 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css" 2 | import type { AppProps } from "next/app" 3 | import { Analytics } from "@vercel/analytics/react" 4 | import { MagicBellProvider } from "@magicbell/react-headless" 5 | import { SubscriptionManager } from "@/services/subscriptionManager" 6 | import { DeviceInfoProvider } from "@/hooks/useDeviceInfo" 7 | 8 | export default function App({ Component, pageProps }: AppProps) { 9 | return ( 10 | <> 11 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from "next/document" 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/api/cron/hn_top_new.ts: -------------------------------------------------------------------------------- 1 | import MagicBell from "magicbell" 2 | import { initializeApp } from "firebase/app" 3 | import { getDatabase, ref, get, limitToFirst, query } from "firebase/database" 4 | import type { NextApiRequest, NextApiResponse } from "next" 5 | import { topics } from "@/constants/topics" 6 | 7 | interface Request extends NextApiRequest { 8 | body: {} 9 | } 10 | 11 | type ResponseData = { 12 | status: string 13 | } 14 | 15 | type Story = { 16 | by: string 17 | descendants: number 18 | id: number 19 | kids: number[] 20 | score: number 21 | time: number 22 | title: string 23 | type: "story" 24 | url: string 25 | } 26 | 27 | const magicbell = new MagicBell({ 28 | apiKey: process.env.NEXT_PUBLIC_MAGICBELL_API_KEY, 29 | apiSecret: process.env.MAGICBELL_API_SECRET, 30 | }) 31 | 32 | const firebaseConfig = { 33 | databaseURL: "https://hacker-news.firebaseio.com", 34 | } 35 | 36 | const app = initializeApp(firebaseConfig) 37 | const db = getDatabase(app) 38 | 39 | export default async function handler( 40 | req: Request, 41 | res: NextApiResponse 42 | ) { 43 | if (req.query.key !== process.env.VERCEL_CRON_KEY) { 44 | res.status(404).end() 45 | return 46 | } 47 | 48 | const docRef = query(ref(db, "v0/topstories"), limitToFirst(5)) 49 | 50 | await get(docRef).then(async (snapshot) => { 51 | if (snapshot.exists()) { 52 | const items = snapshot.val() 53 | const fullItems: Story[] = await Promise.all( 54 | items.map((item: number) => 55 | get(ref(db, `v0/item/${item}`)).then((snapshot) => snapshot.val()) 56 | ) 57 | ) 58 | fullItems.sort((a, b) => b.score - a.score) 59 | // TODO: check for the first un-notified item 60 | const firstUnNotifiedItem = fullItems[0] 61 | return fetch("https://api.magicbell.com/notifications", { 62 | headers: { 63 | accept: "application/json", 64 | "content-type": "application/json", 65 | "X-MAGICBELL-API-KEY": process.env.NEXT_PUBLIC_MAGICBELL_API_KEY, 66 | "X-MAGICBELL-API-SECRET": process.env.MAGICBELL_API_SECRET, 67 | }, 68 | method: "POST", 69 | body: JSON.stringify({ 70 | notification: { 71 | title: `(${firstUnNotifiedItem.score}) ${firstUnNotifiedItem.title}`, 72 | action_url: firstUnNotifiedItem.url, 73 | category: "default", 74 | topic: topics["HN Top New"].id, 75 | recipients: [ 76 | { 77 | topic: { 78 | subscribers: true, 79 | }, 80 | }, 81 | ], 82 | }, 83 | }), 84 | }).then((response) => response.json()) 85 | } else { 86 | console.log("No data available") 87 | return "no data" 88 | } 89 | }) 90 | 91 | res.status(200).json({ 92 | status: "success", 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /src/pages/api/cron/hn_top_story.ts: -------------------------------------------------------------------------------- 1 | import MagicBell from "magicbell" 2 | import { initializeApp } from "firebase/app" 3 | import { getDatabase, ref, get, limitToFirst, query } from "firebase/database" 4 | import type { NextApiRequest, NextApiResponse } from "next" 5 | import { topics } from "@/constants/topics" 6 | 7 | interface Request extends NextApiRequest { 8 | body: {} 9 | } 10 | 11 | type ResponseData = { 12 | status: string 13 | } 14 | 15 | type Story = { 16 | by: string 17 | descendants: number 18 | id: number 19 | kids: number[] 20 | score: number 21 | time: number 22 | title: string 23 | type: "story" 24 | url: string 25 | } 26 | 27 | const magicbell = new MagicBell({ 28 | apiKey: process.env.NEXT_PUBLIC_MAGICBELL_API_KEY, 29 | apiSecret: process.env.MAGICBELL_API_SECRET, 30 | }) 31 | 32 | const firebaseConfig = { 33 | databaseURL: "https://hacker-news.firebaseio.com", 34 | } 35 | 36 | const app = initializeApp(firebaseConfig) 37 | const db = getDatabase(app) 38 | 39 | export default async function handler( 40 | req: Request, 41 | res: NextApiResponse 42 | ) { 43 | if (req.query.key !== process.env.VERCEL_CRON_KEY) { 44 | res.status(404).end() 45 | return 46 | } 47 | 48 | const docRef = query(ref(db, "v0/topstories"), limitToFirst(5)) 49 | 50 | await get(docRef).then(async (snapshot) => { 51 | if (snapshot.exists()) { 52 | const items = snapshot.val() 53 | const fullItems: Story[] = await Promise.all( 54 | items.map((item: number) => 55 | get(ref(db, `v0/item/${item}`)).then((snapshot) => snapshot.val()) 56 | ) 57 | ) 58 | const firstUnNotifiedItem = fullItems[0] 59 | return fetch("https://api.magicbell.com/notifications", { 60 | headers: { 61 | accept: "application/json", 62 | "content-type": "application/json", 63 | "X-MAGICBELL-API-KEY": process.env.NEXT_PUBLIC_MAGICBELL_API_KEY, 64 | "X-MAGICBELL-API-SECRET": process.env.MAGICBELL_API_SECRET, 65 | }, 66 | method: "POST", 67 | body: JSON.stringify({ 68 | notification: { 69 | title: `(${firstUnNotifiedItem.score}) ${firstUnNotifiedItem.title}`, 70 | action_url: firstUnNotifiedItem.url, 71 | category: "default", 72 | topic: topics["HN Top Story"].id, 73 | recipients: [ 74 | { 75 | topic: { 76 | subscribers: true, 77 | }, 78 | }, 79 | ], 80 | }, 81 | }), 82 | }).then((response) => response.json()) 83 | } else { 84 | console.log("No data available") 85 | } 86 | }) 87 | 88 | res.status(200).json({ status: "success" }) 89 | } 90 | -------------------------------------------------------------------------------- /src/pages/api/hn_random.ts: -------------------------------------------------------------------------------- 1 | import MagicBell from "magicbell" 2 | import { initializeApp } from "firebase/app" 3 | import { getDatabase, ref, get, limitToFirst, query } from "firebase/database" 4 | import type { NextApiRequest, NextApiResponse } from "next" 5 | 6 | interface WelcomeRequest extends NextApiRequest { 7 | body: { 8 | userId: string 9 | } 10 | } 11 | 12 | type ResponseData = { 13 | status: string 14 | } 15 | 16 | type Story = { 17 | by: string 18 | descendants: number 19 | id: number 20 | kids: number[] 21 | score: number 22 | time: number 23 | title: string 24 | type: "story" 25 | url: string 26 | } 27 | 28 | const magicbell = new MagicBell({ 29 | apiKey: process.env.NEXT_PUBLIC_MAGICBELL_API_KEY, 30 | apiSecret: process.env.MAGICBELL_API_SECRET, 31 | }) 32 | 33 | const firebaseConfig = { 34 | databaseURL: "https://hacker-news.firebaseio.com", 35 | } 36 | 37 | const app = initializeApp(firebaseConfig) 38 | const db = getDatabase(app) 39 | 40 | export default async function handler( 41 | req: WelcomeRequest, 42 | res: NextApiResponse 43 | ) { 44 | const docRef = query(ref(db, "v0/topstories"), limitToFirst(30)) 45 | 46 | await get(docRef).then(async (snapshot) => { 47 | if (snapshot.exists()) { 48 | const items = snapshot.val() 49 | const fullItems: Story[] = await Promise.all( 50 | items.map((item: number) => 51 | get(ref(db, `v0/item/${item}`)).then((snapshot) => snapshot.val()) 52 | ) 53 | ) 54 | const randomItem = fullItems[Math.floor(Math.random() * fullItems.length)] 55 | return magicbell.notifications.create({ 56 | title: `(${randomItem.score}) ${randomItem.title}`, 57 | content: randomItem.url, 58 | action_url: randomItem.url, 59 | recipients: [{ external_id: req.body.userId }], 60 | category: "default", 61 | }) 62 | } else { 63 | console.log("No data available") 64 | } 65 | }) 66 | 67 | res.status(200).json({ status: "success" }) 68 | } 69 | -------------------------------------------------------------------------------- /src/pages/api/hn_top_new.ts: -------------------------------------------------------------------------------- 1 | import MagicBell from "magicbell" 2 | import { initializeApp } from "firebase/app" 3 | import { 4 | getDatabase, 5 | ref, 6 | get, 7 | limitToFirst, 8 | query, 9 | orderByChild, 10 | } from "firebase/database" 11 | import type { NextApiRequest, NextApiResponse } from "next" 12 | import { topics } from "@/constants/topics" 13 | 14 | interface WelcomeRequest extends NextApiRequest { 15 | body: { 16 | userId: string 17 | } 18 | } 19 | 20 | type ResponseData = { 21 | status: string 22 | } 23 | 24 | type Story = { 25 | by: string 26 | descendants: number 27 | id: number 28 | kids: number[] 29 | score: number 30 | time: number 31 | title: string 32 | type: "story" 33 | url: string 34 | } 35 | 36 | const magicbell = new MagicBell({ 37 | apiKey: process.env.NEXT_PUBLIC_MAGICBELL_API_KEY, 38 | apiSecret: process.env.MAGICBELL_API_SECRET, 39 | }) 40 | 41 | const firebaseConfig = { 42 | databaseURL: "https://hacker-news.firebaseio.com", 43 | } 44 | 45 | const app = initializeApp(firebaseConfig) 46 | const db = getDatabase(app) 47 | 48 | export default async function handler( 49 | req: WelcomeRequest, 50 | res: NextApiResponse 51 | ) { 52 | const docRef = query(ref(db, "v0/newstories"), limitToFirst(40)) 53 | 54 | await get(docRef).then(async (snapshot) => { 55 | if (snapshot.exists()) { 56 | const items = snapshot.val() 57 | const fullItems: Story[] = await Promise.all( 58 | items.map((item: number) => 59 | get(ref(db, `v0/item/${item}`)).then((snapshot) => snapshot.val()) 60 | ) 61 | ) 62 | fullItems.sort((a, b) => b.score - a.score) 63 | const firstUnNotifiedItem = fullItems[0] 64 | return magicbell.notifications.create({ 65 | title: `New: ${firstUnNotifiedItem.title}`, 66 | action_url: firstUnNotifiedItem.url, 67 | recipients: [ 68 | { external_id: req.body.userId }, 69 | ], 70 | category: "default", 71 | topic: topics["HN Top New"].id, 72 | }) 73 | } else { 74 | console.log("No data available") 75 | } 76 | }) 77 | 78 | res.status(200).json({ status: "success" }) 79 | } 80 | -------------------------------------------------------------------------------- /src/pages/api/hn_top_story.ts: -------------------------------------------------------------------------------- 1 | import MagicBell from "magicbell" 2 | import { initializeApp } from "firebase/app" 3 | import { getDatabase, ref, get, limitToFirst, query } from "firebase/database" 4 | import type { NextApiRequest, NextApiResponse } from "next" 5 | import { topics } from "@/constants/topics" 6 | 7 | interface WelcomeRequest extends NextApiRequest { 8 | body: { 9 | userId: string 10 | } 11 | } 12 | 13 | type ResponseData = { 14 | status: string 15 | } 16 | 17 | type Story = { 18 | by: string 19 | descendants: number 20 | id: number 21 | kids: number[] 22 | score: number 23 | time: number 24 | title: string 25 | type: "story" 26 | url: string 27 | } 28 | 29 | const magicbell = new MagicBell({ 30 | apiKey: process.env.NEXT_PUBLIC_MAGICBELL_API_KEY, 31 | apiSecret: process.env.MAGICBELL_API_SECRET, 32 | }) 33 | 34 | const firebaseConfig = { 35 | databaseURL: "https://hacker-news.firebaseio.com", 36 | } 37 | 38 | const app = initializeApp(firebaseConfig) 39 | const db = getDatabase(app) 40 | 41 | export default async function handler( 42 | req: WelcomeRequest, 43 | res: NextApiResponse 44 | ) { 45 | const docRef = query(ref(db, "v0/topstories"), limitToFirst(5)) 46 | 47 | await get(docRef).then(async (snapshot) => { 48 | if (snapshot.exists()) { 49 | const items = snapshot.val() 50 | const fullItems: Story[] = await Promise.all( 51 | items.map((item: number) => 52 | get(ref(db, `v0/item/${item}`)).then((snapshot) => snapshot.val()) 53 | ) 54 | ) 55 | // TODO: check for the first un-notified item 56 | const firstUnNotifiedItem = fullItems[0] 57 | return magicbell.notifications.create({ 58 | title: `(${firstUnNotifiedItem.score}) ${firstUnNotifiedItem.title}`, 59 | action_url: firstUnNotifiedItem.url, 60 | recipients: [{ external_id: req.body.userId }], 61 | category: "default", 62 | topic: topics["HN Top Story"].id, 63 | }) 64 | } else { 65 | console.log("No data available") 66 | } 67 | }) 68 | 69 | res.status(200).json({ status: "success" }) 70 | } 71 | -------------------------------------------------------------------------------- /src/pages/api/welcome.ts: -------------------------------------------------------------------------------- 1 | import MagicBell from "magicbell" 2 | import type { NextApiRequest, NextApiResponse } from "next" 3 | 4 | interface WelcomeRequest extends NextApiRequest { 5 | body: { 6 | userId: string 7 | } 8 | } 9 | 10 | type ResponseData = { 11 | status: string 12 | } 13 | 14 | const magicbell = new MagicBell({ 15 | apiKey: process.env.NEXT_PUBLIC_MAGICBELL_API_KEY, 16 | apiSecret: process.env.MAGICBELL_API_SECRET, 17 | }) 18 | 19 | export default async function handler( 20 | req: WelcomeRequest, 21 | res: NextApiResponse 22 | ) { 23 | await magicbell.notifications.create({ 24 | title: "Thanks for subscribing!", 25 | action_url: "https://magicbell.com", 26 | recipients: [{ external_id: req.body.userId }], 27 | category: "default", 28 | }) 29 | res.status(200).json({ status: "success" }) 30 | } 31 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from "next/font/google"; 2 | import Head from "next/head"; 3 | import { useEffect, useState } from "react"; 4 | 5 | import ContentWrapper from "@/components/content-wrapper"; 6 | import Disclaimer, { magicBellHandle } from "@/components/disclaimer"; 7 | import ErrorDiagnostics from "@/components/error-diagnostics"; 8 | import Footer from "@/components/footer"; 9 | import IosInstructionalStatic from "@/components/ios-instructional-static"; 10 | import Links from "@/components/links"; 11 | import PostSubscribeActions from "@/components/post-subscribe-actions"; 12 | import SeoText from "@/components/seo-text"; 13 | import Subscriber from "@/components/subscriber"; 14 | import useDeviceInfo, { DeviceInfo } from "@/hooks/useDeviceInfo"; 15 | import minVersionCheck from "@/utils/minVersionCheck"; 16 | 17 | const inter = Inter({ subsets: ["latin"] }); 18 | 19 | const resendDelay = 10 * 1000; 20 | const enableSuccessMessage = false; 21 | 22 | export type State = 23 | | { status: "idle" | "busy" | "success" } 24 | | { status: "error"; error: string } 25 | | { status: "unsupported" }; 26 | 27 | export default function Home() { 28 | const [footerOpen, setFooterOpen] = useState(false); 29 | const [canResendNotification, setCanResendNotification] = useState(false); 30 | const [state, setState] = useState({ status: "idle" }); 31 | const info = useDeviceInfo(); 32 | 33 | function anticipateSubscriptionFailure(info: DeviceInfo) { 34 | if (info.osName === "iOS") { 35 | if (minVersionCheck(info.osVersion.toString(), 16, 5)) { 36 | if (!info.standalone) return ; 37 | } else { 38 | return ( 39 |

40 | This web push notifications demo requires iOS 16.5 or later. Please 41 | run a software update to continue. 42 |

43 | ); 44 | } 45 | } 46 | if (info.isPrivate) { 47 | return ( 48 |

49 | This web push notifications demo requires a non-private browser 50 | window, since the{" "} 51 | 56 | Notification API 57 | {" "} 58 | is set to "denied" by default. 59 |

60 | ); 61 | } 62 | return null; 63 | } 64 | 65 | function actions(state: State) { 66 | if (!info) { 67 | return null; 68 | } 69 | if (state.status === "success" || info.subscriptionState === "subscribed") { 70 | return ( 71 | { 74 | setCanResendNotification(false); 75 | setTimeout(() => { 76 | setCanResendNotification(true); 77 | }, resendDelay); 78 | }} 79 | onError={(error) => { 80 | setState({ status: "error", error }); 81 | }} 82 | /> 83 | ); 84 | } 85 | 86 | if (anticipateSubscriptionFailure(info)) { 87 | return anticipateSubscriptionFailure(info); 88 | } 89 | 90 | return ; 91 | } 92 | 93 | function result(state: State) { 94 | if (state.status === "idle" || state.status === "busy") { 95 | return; 96 | } 97 | if (state.status === "error") { 98 | return ( 99 | <> 100 | 101 | 102 | ); 103 | } 104 | if (state.status === "success" && enableSuccessMessage) { 105 | return ( 106 | <> 107 |
108 |

109 | You should soon receive a notification on your device. 110 |

111 |

112 | If not, first try checking your browser notification settings at 113 | the operating system level (it is possible that notifications are 114 | muted for your current browser). 115 |

116 |

117 | If this does not explain it, we would love it if you could tag us{" "} 118 | 123 | @magicbell_io 124 | 125 | , with reference to your device settings displayed below. 126 |

127 |
128 | 129 | ); 130 | } 131 | } 132 | 133 | useEffect(() => { 134 | if (state.status === "error") { 135 | setFooterOpen(true); 136 | } 137 | }, [state.status]); 138 | 139 | useEffect(() => { 140 | if (state.status === "success") { 141 | setTimeout(() => { 142 | setCanResendNotification(true); 143 | }, resendDelay); 144 | } else if (info?.subscriptionState === "subscribed") { 145 | setCanResendNotification(true); 146 | } 147 | }, [state.status, info?.subscriptionState]); 148 | 149 | return ( 150 | <> 151 |
157 |

Web Push Notifications Demo

158 |
159 | 160 | 161 | Web Push Notifications on iOS Demo & Test 162 | 167 | 171 | 175 | 176 | 177 | 178 | 179 | 180 | 181 |
182 | {!info ? ( 183 |
Fetching Info
184 | ) : ( 185 |
186 | {actions(state)} 187 | {result(state)} 188 | 189 | 190 | 191 |
192 | )} 193 |
194 |