Add allowing push notifications

This commit is contained in:
pikiou
2022-01-11 13:54:34 +01:00
parent db6d296f05
commit c7941aefc3
16 changed files with 479 additions and 66 deletions

View File

@@ -1,20 +1,30 @@
import _ from "lodash"
import React, { memo, useCallback, useState } from "react"
import { AppDispatch } from "../../store"
import React, { memo, useCallback, useEffect, useRef, useState } from "react"
import isNode from "detect-node"
import { shallowEqual, useSelector } from "react-redux"
import { AppDispatch, AppState } from "../../store"
import { fetchVolunteerNotifsSet } from "../../store/volunteerNotifsSet"
import { VolunteerNotifs } from "../../services/volunteers"
import styles from "./styles.module.scss"
import { logoutUser } from "../../store/auth"
import { unsetJWT } from "../../services/auth"
import { VolunteerNotifs } from "../../services/volunteers"
interface Props {
dispatch: AppDispatch
jwt: string
// eslint-disable-next-line react/require-default-props
volunteerNotifs?: VolunteerNotifs
}
let prevNotifs: VolunteerNotifs | undefined
const Notifications = ({ dispatch, jwt }: Props): JSX.Element | null => {
const volunteerNotifs = useSelector((state: AppState) => {
const notifs = state.volunteerNotifsSet.entity
if (notifs) {
prevNotifs = notifs
return notifs
}
return prevNotifs
}, shallowEqual)
const Notifications = ({ dispatch, jwt, volunteerNotifs }: Props): JSX.Element => {
const hidden = volunteerNotifs?.hiddenNotifs || []
const notifs: JSX.Element[] = []
@@ -78,41 +88,46 @@ const Notifications = ({ dispatch, jwt, volunteerNotifs }: Props): JSX.Element =
<form onSubmit={onSubmit2}>
Si les conditions sanitaires te le permettent, souhaites-tu être
bénévole à PeL 2022 ?<br />
<input
type="radio"
value="inconnu"
name="gender"
checked={participation === "inconnu"}
onChange={onChangeValue2}
/>{" "}
-<br />
<input
type="radio"
value="oui"
name="gender"
checked={participation === "oui"}
onChange={onChangeValue2}
/>{" "}
Oui
<br />
<input
type="radio"
value="non"
name="gender"
checked={participation === "non"}
onChange={onChangeValue2}
/>{" "}
Non
<br />
<input
type="radio"
value="peut-etre"
name="gender"
checked={participation === "peut-etre"}
onChange={onChangeValue2}
/>{" "}
Je ne sais pas encore
<br />
<label>
<input
type="radio"
value="inconnu"
name="gender"
checked={participation === "inconnu"}
onChange={onChangeValue2}
/>{" "}
-
</label>
<label>
<input
type="radio"
value="oui"
name="gender"
checked={participation === "oui"}
onChange={onChangeValue2}
/>{" "}
Oui
</label>
<label>
<input
type="radio"
value="non"
name="gender"
checked={participation === "non"}
onChange={onChangeValue2}
/>{" "}
Non
</label>
<label>
<input
type="radio"
value="peut-etre"
name="gender"
checked={participation === "peut-etre"}
onChange={onChangeValue2}
/>{" "}
Je ne sais pas encore
</label>
{participation === "peut-etre" ? (
<div>
On te le reproposera dans quelques temps.
@@ -149,6 +164,212 @@ Rejoindre les 86 bénévoles déjà présents sur le serveur se fait en cliquant
Tu n'y es absolument pas obligé(e) ! C'est juste plus pratique.
*/
const [acceptsNotifs, setAcceptsNotifs] = useState("")
const [notifMessage, setNotifMessage] = useState("")
const mounted = useRef(false)
useEffect(() => {
if (mounted.current) {
return
}
mounted.current = true
if (!isNode) {
if (volunteerNotifs?.acceptsNotifs === "oui") {
navigator.serviceWorker.ready.then((registration) =>
registration.pushManager.getSubscription().then((existedSubscription) => {
const doesAcceptNotifs =
_.isEqual(
JSON.parse(JSON.stringify(existedSubscription)),
JSON.parse(volunteerNotifs?.pushNotifSubscription)
) && volunteerNotifs?.acceptsNotifs === "oui"
setAcceptsNotifs(doesAcceptNotifs ? "oui" : "non")
})
)
} else {
setAcceptsNotifs("non")
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onChangePushNotifs = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
event.preventDefault()
if (isNode) {
return
}
if (!("serviceWorker" in navigator)) {
return
}
const isChecked = event.target.value === "oui"
if (!isChecked) {
setNotifMessage("")
setAcceptsNotifs("non")
dispatch(
fetchVolunteerNotifsSet(jwt, 0, {
acceptsNotifs: "non",
})
)
return
}
const registration = await navigator.serviceWorker.ready
if (!registration.pushManager) {
setNotifMessage(
"Il y a un problème avec le push manager. Il faudrait utiliser un navigateur plus récent !"
)
return
}
const convertedVapidKey = urlBase64ToUint8Array(
process.env.FORCE_ORANGE_PUBLIC_VAPID_KEY
)
function urlBase64ToUint8Array(base64String?: string) {
if (!base64String) {
return ""
}
const padding = "=".repeat((4 - (base64String.length % 4)) % 4)
// eslint-disable-next-line
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/")
const rawData = atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; i += 1) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
if (!convertedVapidKey) {
console.error("No convertedVapidKey available")
}
try {
const existedSubscription = await registration.pushManager.getSubscription()
if (existedSubscription === null) {
// No subscription detected, make a request
try {
const newSubscription = await registration.pushManager.subscribe({
applicationServerKey: convertedVapidKey,
userVisibleOnly: true,
})
// New subscription added
if (
volunteerNotifs?.acceptsNotifs === "oui" &&
!subscriptionEqualsSave(
newSubscription,
volunteerNotifs?.pushNotifSubscription
)
) {
setNotifMessage(
"Un autre navigateur était notifié, mais c'est maintenant celui-ci qui le sera."
)
} else {
setNotifMessage("C'est enregistré !")
}
setAcceptsNotifs("oui")
dispatch(
fetchVolunteerNotifsSet(jwt, 0, {
pushNotifSubscription: JSON.stringify(newSubscription),
acceptsNotifs: "oui",
})
)
} catch (_e) {
if (Notification.permission !== "granted") {
setNotifMessage(
"Mince tu as bloqué toutes notifications pour le site des bénévoles, l'exact opposé de ce qu'il fallait :) Pour annuler ça, les instructions sont ici : https://support.pushcrew.com/support/solutions/articles/9000098467-how-to-unblock-notifications-from-a-website-that-you-once-blocked-"
)
} else {
setNotifMessage(
"Il y a eu une erreur avec l'enregistrement avec le Service Worker. Il faudrait utiliser un navigateur plus récent !"
)
}
}
} else {
// Existed subscription detected
if (
volunteerNotifs?.acceptsNotifs === "oui" &&
!subscriptionEqualsSave(
existedSubscription,
volunteerNotifs?.pushNotifSubscription
)
) {
setNotifMessage(
"Un autre navigateur était notifié, mais c'est maintenant celui-ci qui le sera."
)
} else {
setNotifMessage("C'est enregistré !")
}
setAcceptsNotifs("oui")
dispatch(
fetchVolunteerNotifsSet(jwt, 0, {
pushNotifSubscription: JSON.stringify(existedSubscription),
acceptsNotifs: "oui",
})
)
}
} catch (_e) {
setNotifMessage(
"Il y a eu une erreur avec l'enregistrement avec le Service Worker. Il faudrait utiliser un navigateur plus récent !"
)
}
},
[dispatch, jwt, volunteerNotifs]
)
function subscriptionEqualsSave(toCheck: PushSubscription, save: string | undefined): boolean {
if (!save) {
return !toCheck
}
return _.isEqual(JSON.parse(JSON.stringify(toCheck)), JSON.parse(save))
}
if (notifs.length === 0) {
notifs.push(
<div key="pushNotifs">
<div className={styles.notificationsPage}>
<div className={styles.notificationsContent}>
<div className={styles.formLine} key="line-participation">
<label>
Acceptes-tu d&apos;être notifié(e) ici quand on aura des questions
ou informations importantes pour toi ?
<label>
<input
type="radio"
value="oui"
name="gender"
checked={acceptsNotifs === "oui"}
onChange={onChangePushNotifs}
/>{" "}
Oui
</label>
<label>
<input
type="radio"
value="non"
name="gender"
checked={acceptsNotifs === "non"}
onChange={onChangePushNotifs}
/>{" "}
Non
</label>
</label>
<div className={styles.message}>{notifMessage}</div>
</div>
</div>
</div>
</div>
)
}
const onClick = useCallback(
(event: React.SyntheticEvent): void => {
event.preventDefault()
@@ -166,6 +387,10 @@ Tu n'y es absolument pas obligé(e) ! C'est juste plus pratique.
</div>
)
if (volunteerNotifs === undefined) {
return null
}
return <div>{notifs.map<React.ReactNode>((t) => t).reduce((prev, curr) => [prev, curr])}</div>
}

View File

@@ -7,6 +7,8 @@
.notificationsContent {
@include page-content-wrapper;
background-color: #ece3df;
}
.notifIntro {
@@ -19,6 +21,7 @@
label {
display: block;
margin-left: 5px;
margin-top: 7px;
}
select,
input {
@@ -34,6 +37,12 @@
text-align: center;
}
.message {
margin-top: 10px;
color: rgb(11, 138, 0);
text-align: center;
}
.error {
margin-top: 10px;
color: rgb(255, 0, 0);

View File

@@ -12,20 +12,14 @@ export type Props = RouteComponentProps
const HomePage: FC<Props> = (): JSX.Element => {
const dispatch = useDispatch()
const readyStatus = useSelector((state: AppState) => state.volunteerNotifsSet.readyStatus)
const volunteerNotifs = useSelector(
(state: AppState) => state.volunteerNotifsSet.entity,
shallowEqual
)
const loginError = useSelector((state: AppState) => state.volunteerLogin.error, shallowEqual)
const jwt = useSelector((state: AppState) => state.auth.jwt, shallowEqual)
if (!readyStatus || readyStatus === "idle" || readyStatus === "request")
return <p>Loading...</p>
if (jwt === undefined) return <p>Loading...</p>
if (jwt) {
return <Notifications dispatch={dispatch} jwt={jwt} volunteerNotifs={volunteerNotifs} />
return <Notifications dispatch={dispatch} jwt={jwt} />
}
return (
<div>

View File

@@ -8,7 +8,8 @@ import { GoogleSpreadsheet, GoogleSpreadsheetWorksheet } from "google-spreadshee
const CRED_PATH = path.resolve(process.cwd(), "access/gsheets.json")
const REMOTE_UPDATE_DELAY = 20000
const REMOTE_UPDATE_DELAY = 40000
const DELAY_AFTER_QUERY = 1000
export type ElementWithId<ElementNoId> = { id: number } & ElementNoId
@@ -26,14 +27,6 @@ export const sheetNames = new SheetNames()
// eslint-disable-next-line @typescript-eslint/ban-types
type SheetList = { [sheetName in keyof SheetNames]?: Sheet<object, ElementWithId<object>> }
const sheetList: SheetList = {}
setInterval(
() =>
// eslint-disable-next-line @typescript-eslint/ban-types
Object.values(sheetList).forEach((sheet: Sheet<object, ElementWithId<object>>) =>
sheet.dbUpdate()
),
REMOTE_UPDATE_DELAY
)
export function getSheet<
// eslint-disable-next-line @typescript-eslint/ban-types
@@ -48,7 +41,14 @@ export function getSheet<
sheetList[sheetName] = new Sheet<ElementNoId, Element>(sheetName, specimen, translation)
}
return sheetList[sheetName] as Sheet<ElementNoId, Element>
const sheet = sheetList[sheetName] as Sheet<ElementNoId, Element>
setTimeout(
() => setInterval(() => sheet.dbUpdate(), REMOTE_UPDATE_DELAY),
1000 * Object.values(sheetList).length
)
return sheet
}
export class Sheet<
@@ -83,7 +83,7 @@ export class Sheet<
(englishProp: string) => (specimen as any)[englishProp]
) as Element
this.dbLoad()
setTimeout(() => this.dbLoad(), 100 * Object.values(sheetList).length)
}
async getList(): Promise<Element[] | undefined> {
@@ -198,6 +198,8 @@ export class Sheet<
if (!row) {
// eslint-disable-next-line no-await-in-loop
await sheet.addRow(stringifiedRow)
// eslint-disable-next-line no-await-in-loop
await delayDBAccess()
} else {
const keys = Object.keys(stringifiedRow)
const sameCells = _.every(keys, (key: keyof Element) => {
@@ -211,6 +213,8 @@ export class Sheet<
})
// eslint-disable-next-line no-await-in-loop
await row.save()
// eslint-disable-next-line no-await-in-loop
await delayDBAccess()
}
}
@@ -222,6 +226,8 @@ export class Sheet<
if (rows[rowToDelete]) {
// eslint-disable-next-line no-await-in-loop
await rows[rowToDelete].delete()
// eslint-disable-next-line no-await-in-loop
await delayDBAccess()
}
}
}
@@ -233,6 +239,7 @@ export class Sheet<
// Load sheet into an array of objects
const rows = (await sheet.getRows()) as StringifiedElement[]
await delayDBAccess()
const elements: Element[] = []
if (!rows[0]) {
throw new Error(`No column types defined in sheet ${this.name}`)
@@ -495,3 +502,7 @@ function parseDate(value: string): Date {
}
return new Date(+matchDate[1], +matchDate[2] - 1, +matchDate[3])
}
async function delayDBAccess(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, DELAY_AFTER_QUERY))
}

View File

@@ -112,6 +112,8 @@ export const volunteerNotifsSet = expressAccessor.set(
adult: newVolunteer.adult,
active: newVolunteer.active,
hiddenNotifs: newVolunteer.hiddenNotifs,
pushNotifSubscription: newVolunteer.pushNotifSubscription,
acceptsNotifs: newVolunteer.acceptsNotifs,
} as VolunteerNotifs,
}
}

View File

@@ -27,6 +27,7 @@ import {
volunteerForgot,
} from "./gsheets/volunteers"
import config from "../config"
import notificationsSubscribe from "./notificationsSubscribe"
const app = express()
@@ -70,6 +71,9 @@ app.post("/VolunteerSet", secure as RequestHandler, volunteerSet)
// UNSAFE app.post("/VolunteerGet", secure as RequestHandler, volunteerGet)
app.post("/VolunteerNotifsSet", secure as RequestHandler, volunteerNotifsSet)
// Push notification subscription
app.post("/notifications/subscribe", notificationsSubscribe)
// Use React server-side rendering middleware
app.get("*", ssr)

View File

@@ -0,0 +1,29 @@
import { Request, Response, NextFunction } from "express"
import webpush from "web-push"
const publicKey = process.env.FORCE_ORANGE_PUBLIC_VAPID_KEY
const privateKey = process.env.FORCE_ORANGE_PRIVATE_VAPID_KEY
if (publicKey && privateKey) {
webpush.setVapidDetails("mailto: contact@parisestludique.fr", publicKey, privateKey)
}
export default function notificationsSubscribe(
request: Request,
response: Response,
_next: NextFunction
): void {
const subscription = request.body
console.log(subscription)
const payload = JSON.stringify({
title: "Hello!",
body: "It works.",
})
webpush
.sendNotification(subscription, payload)
.then((result) => console.log(result))
.catch((e) => console.log(e.stack))
response.status(200).json({ success: true })
}

View File

@@ -31,6 +31,37 @@ export default (
${extractor.getStyleTags()}
</head>
<body>
<script>
window.isSubscribed = false;
window.swRegistration = null;
//REGISTER AND UNREGISTER SERVICE WORKER
if ('serviceWorker' in navigator && 'PushManager' in window) {
window.addEventListener('load', function() {
var swPath = '/service-worker.js';
navigator.serviceWorker.register(swPath)
.then(function(registration) {
console.log('Service Worker registered');
window.swRegistration = registration;
registration.pushManager.getSubscription()
.then(function(subscription) {
console.log("subscription", JSON.stringify(subscription));
window.isSubscribed = !(subscription === null);
if (window.isSubscribed) {
console.log('User IS subscribed.');
} else {
console.log('User is NOT subscribed.');
}
});
})
.catch(function(err) {
console.log('Service Worker registration failed: ', err);
});
});
}
</script>
<!-- Insert the router, which passed from server-side -->
<div id="react-view">${htmlContent}</div>

View File

@@ -28,6 +28,10 @@ export class Volunteer {
password1 = ""
password2 = ""
pushNotifSubscription = ""
acceptsNotifs = ""
}
export const translationVolunteer: { [k in keyof Volunteer]: string } = {
@@ -45,6 +49,8 @@ export const translationVolunteer: { [k in keyof Volunteer]: string } = {
created: "creation",
password1: "passe1",
password2: "passe2",
pushNotifSubscription: "pushNotifSubscription",
acceptsNotifs: "accepteLesNotifs",
}
const elementName = "Volunteer"
@@ -64,6 +70,8 @@ export const volunteerExample: Volunteer = {
created: new Date(0),
password1: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
password2: "$2y$10$fSxY9AIuxSiEjwF.J3eXGubIxUPkdq9d5fqpbl8ASimSjNj4SR.9O",
pushNotifSubscription: "",
acceptsNotifs: "",
}
export const emailRegexp =
@@ -97,6 +105,8 @@ export interface VolunteerNotifs {
adult: number
active: string
hiddenNotifs: number[]
pushNotifSubscription: string
acceptsNotifs: string
}
export const volunteerNotifsSet =
serviceAccessors.securedCustomPost<[number, Partial<VolunteerNotifs>]>("NotifsSet")

View File

@@ -3,7 +3,6 @@ declare const __SERVER__: boolean
declare const __DEV__: boolean
declare const __LOCAL__: boolean
declare const __TEST__: boolean
declare const __SENDGRID_API_KEY__: string
declare module "*.svg"
declare module "*.gif"
@@ -21,10 +20,15 @@ declare namespace NodeJS {
__DEV__: boolean
__LOCAL__: boolean
__TEST__: boolean
__SENDGRID_API_KEY__: string
$RefreshReg$: () => void
$RefreshSig$$: () => void
}
interface ProcessEnv {
SENDGRID_API_KEY?: string
FORCE_ORANGE_PUBLIC_VAPID_KEY?: string
FORCE_ORANGE_PRIVATE_VAPID_KEY?: string
}
}
interface Window {