Skip to content

Commit

Permalink
feat: notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
Hanssen0 committed Jan 21, 2024
1 parent 71496c0 commit eade443
Show file tree
Hide file tree
Showing 5 changed files with 282 additions and 63 deletions.
47 changes: 39 additions & 8 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,45 @@
import * as React from "react";
import { ChakraProvider } from "@chakra-ui/react";
import { Home } from "@/pages/Home";
import { theme } from "./theme";
import { SpeedInsights } from "@vercel/speed-insights/react";
import { Analytics } from "@vercel/analytics/react";
import {
NotificationProvider,
useSendNotification,
} from "./components/Notifications";
import { useEffect } from "react";
import { ServiceWorkerStatus, onceSW } from "./utils/serviceWorker";

export const App = () => (
<ChakraProvider theme={theme}>
<Analytics />
<SpeedInsights />
<Home />
</ChakraProvider>
);
function InnerApp() {
const send = useSendNotification();

useEffect(() => {
onceSW(ServiceWorkerStatus.Initialized, () =>
send({ content: "Cached for faster loading", timeout: 2500 })
);
onceSW(ServiceWorkerStatus.Pending, () =>
send({ content: "An update. Close all tabs for this page to finish it" })
);
onceSW(ServiceWorkerStatus.Updated, () =>
send({ content: "Update finished", timeout: 3000 })
);
}, [send]);

return (
<>
<Home />
</>
);
}

export function App() {
return (
<ChakraProvider theme={theme}>
<Analytics />
<SpeedInsights />
<NotificationProvider>
<InnerApp />
</NotificationProvider>
</ChakraProvider>
);
}
142 changes: 142 additions & 0 deletions src/components/Notifications/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { Fields } from "@/utils";
import { Box, Container, Slide, chakra } from "@chakra-ui/react";
import {
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { FaXmark as FaXmarkRaw } from "react-icons/fa6";

const FaXmark = chakra(FaXmarkRaw);

export class Notification {
readonly id: number;
readonly content: ReactNode;
readonly timeout: number;

constructor({ id, content, timeout }: Fields<Notification>) {
this.id = id;
this.content = content;
this.timeout = timeout;
}
}

const NotificationContext = createContext<{
notifications: Notification[];
sendNotification: (n: Notification) => void;
removeNotification: (id: number) => void;
}>({
notifications: [],
sendNotification: () => {},
removeNotification: () => {},
});

let NOTIFICATIONS = 0;

function NotificationsContainer() {
const [status, setStatus] = useState<number>(0);
const {
notifications: [notification],
removeNotification,
} = useContext(NotificationContext);

useEffect(() => {
if (notification && status === 0) {
setStatus(1);
setTimeout(() => setStatus(2), notification.timeout);
}
}, [status, notification, removeNotification]);

if (!notification) {
return undefined;
}

return (
<Slide
direction="bottom"
in={status === 1}
style={{ zIndex: 99999999 }}
onAnimationEnd={() => {
if (status === 2) {
removeNotification(notification.id);
setStatus(0);
}
}}
onClick={() => {
setStatus(2);
}}
>
<Box
textAlign="center"
cursor="pointer"
borderTopWidth={1}
borderColor="chakra-border-color"
background="chakra-body-bg"
transition="background 0.3s ease-in-out"
_hover={{
_dark: {
background: "gray.700",
},
_light: {
background: "gray.100",
},
}}
py={5}
>
<Container>{notification.content}</Container>
</Box>
<FaXmark
position="absolute"
right={4}
top="50%"
transform="translateY(-50%)"
fill="chakra-placeholder-color"
aria-label="Close mark"
/>
</Slide>
);
}

export function NotificationProvider({ children }: { children: ReactNode }) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const sendNotification = useCallback(
(n: Notification) => setNotifications((ns) => [...ns, n]),
[]
);
const removeNotification = useCallback(
(id: number) => setNotifications((ns) => ns.filter((n) => n.id !== id)),
[]
);

return (
<NotificationContext.Provider
value={{
notifications,
sendNotification,
removeNotification,
}}
>
{children}
<NotificationsContainer />
</NotificationContext.Provider>
);
}

export function useSendNotification() {
const { sendNotification } = useContext(NotificationContext);

return useCallback(
(n: Omit<Fields<Notification>, "id" | "timeout"> & { timeout?: number }) =>
sendNotification(
new Notification({
...n,
id: NOTIFICATIONS++,
timeout: n.timeout ?? 10000,
})
),
[sendNotification]
);
}
3 changes: 2 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as ReactDOM from "react-dom/client";
import { App } from "./App";
import * as serviceWorkerRegistration from "./serviceWorkerRegistration";
import { theme } from "./theme";
import { registrationConfig } from "./utils/serviceWorker";

const container = document.getElementById("root");
if (!container) throw new Error("Failed to find the root element");
Expand All @@ -16,4 +17,4 @@ root.render(
</React.StrictMode>
);

serviceWorkerRegistration.register();
serviceWorkerRegistration.register(registrationConfig);
103 changes: 49 additions & 54 deletions src/serviceWorkerRegistration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,79 +21,74 @@ const isLocalhost = Boolean(
);

type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void;
onInitialized?: (registration: ServiceWorkerRegistration) => void;
onPending?: (registration: ServiceWorkerRegistration) => void;
onActivated?: (registration: ServiceWorkerRegistration) => void;
};

export function register(config?: Config) {
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
if (
process.env.NODE_ENV !== "production" ||
!("serviceWorker" in navigator)
) {
return;
}
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}

window.addEventListener("load", () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
window.addEventListener("load", () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;

if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);

// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
"This web app is being served cache-first by a service " +
"worker. To learn more, visit https://cra.link/PWA"
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
"This web app is being served cache-first by a service " +
"worker. To learn more, visit https://cra.link/PWA"
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}

function registerValidSW(swUrl: string, config?: Config) {
navigator.serviceWorker
.register(swUrl)
.then((registration) => {
if (registration.waiting) {
config?.onPending?.(registration);
} else if (registration.active) {
config?.onActivated?.(registration);
}

registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === "installed") {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
"New content is available and will be used when all " +
"tabs for this page are closed. See https://cra.link/PWA."
);

// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log("Content is cached for offline use.");
installingWorker.onstatechange = () => {
if (installingWorker.state !== "installed") {
return;
}

// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
if (registration.active) {
config?.onPending?.(registration);
} else {
config?.onInitialized?.(registration);
}
};
};
Expand Down
50 changes: 50 additions & 0 deletions src/utils/serviceWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export enum ServiceWorkerStatus {
Unknown = 0,
Initialized,
Pending,
Activated,
Updated,
}

const STATUS_KEY = "chainIdleServiceWorkerStatus";
const PREV_STATUS = Number(localStorage.getItem(STATUS_KEY));
let STATUS = ServiceWorkerStatus.Unknown;

let LISTENERS: { handler: () => void; status: ServiceWorkerStatus }[] = [];

function statusEq(want: ServiceWorkerStatus, received: ServiceWorkerStatus) {
return (
(want === ServiceWorkerStatus.Updated &&
received === ServiceWorkerStatus.Activated &&
PREV_STATUS === ServiceWorkerStatus.Pending) ||
want === received
);
}

function onSW(status: ServiceWorkerStatus) {
STATUS = status;
localStorage.setItem(STATUS_KEY, status.toString());

LISTENERS = LISTENERS.filter((h) => {
if (statusEq(h.status, status)) {
h.handler();
return false;
}
return true;
});
}

export const registrationConfig = {
onInitialized: () => onSW(ServiceWorkerStatus.Initialized),
onPending: () => onSW(ServiceWorkerStatus.Pending),
onActivated: () => onSW(ServiceWorkerStatus.Activated),
};

export function onceSW(status: ServiceWorkerStatus, handler: () => void) {
if (statusEq(status, STATUS)) {
handler();
return;
}

LISTENERS.push({ handler, status });
}

0 comments on commit eade443

Please sign in to comment.