Skip to content

Commit 2e690b8

Browse files
committed
feat(notifications): add notifications on tweet add
1 parent 9e05be0 commit 2e690b8

File tree

19 files changed

+2057
-3551
lines changed

19 files changed

+2057
-3551
lines changed

.env.example

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,9 @@ CLIENT_ID=
2323
MEDIA_ROOT=
2424

2525
# Port HTTP de l'app. :3000 par défaut.
26-
PORT=
26+
PORT=
27+
28+
# VAPID informations for Web Push
29+
PUBLIC_VAPID_KEY=
30+
PRIVATE_VAPID_KEY=
31+
VAPID_EMAIL=

db_init.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ CREATE TABLE `Recover` (
5858
`created_at` DATETIME NOT NULL,
5959
PRIMARY KEY (`token`));
6060

61+
CREATE TABLE `Subscription`(
62+
`user_id` CHAR(16) NOT NULL,
63+
`subscription` json NOT NULL
64+
);
65+
6166
CREATE TABLE `Id` (
6267
`id` CHAR(16) NOT NULL,
6368
`created_at` DATETIME NOT NULL,

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"multer": "^1.4.4",
2323
"mysql2": "^2.3.3",
2424
"nodemailer": "^6.7.2",
25-
"pug": "^3.0.2"
25+
"pug": "^3.0.2",
26+
"web-push": "^3.6.7"
2627
},
2728
"devDependencies": {
2829
"nodemon": "^2.0.15"

public/css/Menu.css

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
.main-menu-button {
1111
display: flex;
12+
align-items: center;
1213
padding: 12px;
1314
border-radius: 30px;
1415
text-decoration: none;
@@ -54,4 +55,80 @@
5455
z-index: 998;
5556
padding: 5px 0;
5657
}
58+
}
59+
60+
#main-menu #allow-push {
61+
display: none;
62+
& div {
63+
font-size: 10pt;
64+
}
65+
&.visible { display: flex;}
66+
}
67+
68+
#notification-container {
69+
display: none;
70+
width: 100%;
71+
height: 100%;
72+
position: fixed;
73+
top: 0;
74+
left: 0;
75+
backdrop-filter: blur(10px);
76+
background: #0004;
77+
justify-content: center;
78+
align-items: center;
79+
z-index: 9999;
80+
&.visible {display: flex;}
81+
}
82+
83+
#notification-popup {
84+
position: relative;
85+
box-sizing: border-box;
86+
width: 300px;
87+
max-width: 90vw;
88+
padding: 20px;
89+
border-radius: 10px;
90+
display: flex;
91+
flex-direction: column;
92+
text-align: center;
93+
background-color: var(--bg-blue);
94+
box-shadow: 0px 20px 20px #0005;
95+
& #beta-banner {
96+
position: absolute;
97+
background: red;
98+
padding: 5px 13px;
99+
border-radius: 5px;
100+
top: 5px;
101+
right: -15px;
102+
transform: rotate(45deg);
103+
font: 10pt 'Montserrat';
104+
}
105+
& i {
106+
font-size: 30pt;
107+
margin: 10px 0;
108+
}
109+
& #notification-title {
110+
font: 700 14pt 'Montserrat';
111+
margin: 10px 0;
112+
}
113+
114+
& #notification-buttons {
115+
width: 100%;
116+
margin: 20px 0;
117+
& div {
118+
cursor: pointer;
119+
margin: 10px 0;
120+
border-radius: 10em;
121+
padding: 5px 10px;
122+
border: 1px solid var(--lighter-grey);
123+
font: 800 12pt 'Montserrat';
124+
transition: .1s ease;
125+
&#accept {
126+
background: var(--main-blue);
127+
border: none;
128+
}
129+
&#decline:hover {
130+
background: #fff1;
131+
}
132+
}
133+
}
57134
}

public/css/main.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
--main-white: #fafafa;
66
--light-grey: #38444d;
77
--lighter-grey: #616d75;
8-
--bg-blue: #151b29;
8+
--bg-blue: #151b29;
99
}
1010

1111
html, body {

public/icon512_maskable.png

17.1 KB
Loading

public/icon512_rounded.png

27.1 KB
Loading

public/js/main.js

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,83 @@ document.addEventListener('keydown', ({key}) => {
1010
input = [];
1111
}
1212

13-
});
13+
});
14+
15+
if ('serviceWorker' in navigator) {
16+
navigator.serviceWorker.register('/sw.js', {
17+
scope: '/',
18+
});
19+
}
20+
21+
const urlBase64ToUint8Array = (base64String) => {
22+
const padding = '='.repeat((4 - base64String.length % 4) % 4);
23+
const base64 = (base64String + padding)
24+
.replace(/\-/g, '+')
25+
.replace(/_/g, '/');
26+
27+
const rawData = window.atob(base64);
28+
const outputArray = new Uint8Array(rawData.length);
29+
30+
for (let i = 0; i < rawData.length; ++i) {
31+
outputArray[i] = rawData.charCodeAt(i);
32+
}
33+
return outputArray;
34+
};
35+
36+
window.subscribe = async () => {
37+
if (!('serviceWorker' in navigator)) return;
38+
39+
const registration = await navigator.serviceWorker.ready;
40+
if (await registration.pushManager.getSubscription() !== null)
41+
return;
42+
43+
const subscription = await registration.pushManager.subscribe({
44+
userVisibleOnly: true,
45+
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID),
46+
});
47+
48+
await fetch('/api/subscription', {
49+
method: 'POST',
50+
body: JSON.stringify(subscription),
51+
headers: {
52+
'content-type': 'application/json',
53+
},
54+
});
55+
}
56+
57+
58+
const notifsAccept = document.querySelector('#notification-popup #accept');
59+
const notifsDecline = document.querySelector('#notification-popup #decline');
60+
const notifsContainer = document.querySelector('#notification-container');
61+
const menuNotifs = document.querySelector('#main-menu #allow-push');
62+
notifsDecline.addEventListener('click', () => {
63+
notifsContainer.remove();
64+
localStorage.setItem('PUSH_WAS_PROMPTED', 'true');
65+
menuNotifs.classList.add('visible');
66+
});
67+
notifsAccept.addEventListener('click',async () => {
68+
subscribe();
69+
notifsContainer.remove();
70+
localStorage.setItem('PUSH_WAS_PROMPTED', 'true');
71+
});
72+
menuNotifs.addEventListener('click', async () => {
73+
subscribe();
74+
menuNotifs.remove();
75+
});
76+
77+
(async () => {
78+
79+
const registration = await navigator.serviceWorker.ready;
80+
const pushAllowed = await registration.pushManager.getSubscription() !== null;
81+
const wasPrompted = localStorage.getItem('PUSH_WAS_PROMPTED') === 'true';
82+
83+
if (!wasPrompted && !pushAllowed) {
84+
notifsContainer.classList.add('visible');
85+
} else {
86+
notifsContainer.remove();
87+
if (!pushAllowed) {
88+
menuNotifs.classList.add('visible');
89+
}
90+
}
91+
})();
92+

public/manifest.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"theme_color": "#151b29",
3+
"background_color": "#1e9bf0",
4+
"icons": [
5+
{
6+
"purpose": "maskable",
7+
"sizes": "512x512",
8+
"src": "icon512_maskable.png",
9+
"type": "image/png"
10+
},
11+
{
12+
"purpose": "any",
13+
"sizes": "512x512",
14+
"src": "icon512_rounded.png",
15+
"type": "image/png"
16+
}
17+
],
18+
"orientation": "portrait",
19+
"display": "standalone",
20+
"dir": "auto",
21+
"lang": "fr-FR",
22+
"name": "Twitter",
23+
"short_name": "Twitter",
24+
"start_url": "https://twitter.paulleflon.fr",
25+
"scope": "https://twitter.paulleflon.fr/"
26+
}

src/app.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ const path = require('path');
66
const Logger = require('./lib/misc/Logger');
77
const app = express();
88
const upload = multer({dest: './uploads'});
9+
const webpushConfig = require('./web-push');
10+
const webpush = require('web-push');
911

1012
// Middleware
1113
app.use(cookieParser(process.env.COOKIE_SECRET));
@@ -24,6 +26,22 @@ app.db = require('./lib/db/Database');
2426
app.use('/', require('./routes/index'));
2527
app.use('/', require('./routes/wall'));
2628
app.use('/api', require('./routes/api'));
29+
app.get('/sw.js', (req, res) => {
30+
res.sendFile('./sw.js', {root: path.join(__dirname)});
31+
});
32+
33+
app.get('/test', async (req, res) => {
34+
const [subs] = await req.app.db.connection.query('SELECT subscription FROM Subscription');
35+
const notification = {title: 'NOTIFICATION'};
36+
const notifs = [];
37+
console.log(subs);
38+
for (const sub of subs) {
39+
notifs.push(webpush.sendNotification(sub.subscription, JSON.stringify(notification)));
40+
}
41+
await Promise.all(notifs).catch((err) => console.log(err));
42+
res.sendStatus(201);
43+
});
44+
webpushConfig();
2745

2846
app.db.connect().then(() => {
2947
app.log.info('Connecté à la base de données.');

src/middleware/auth.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ module.exports = async function(req, res, next) {
55
if (req.path.startsWith('/public') ||
66
req.path.startsWith('/register') ||
77
req.path.startsWith('/renew-password') ||
8-
req.path.startsWith('/recover'))
8+
req.path.startsWith('/recover') ||
9+
req.path.startsWith('/test'))
910
return next();
1011

1112
// Redirige vers l'accueil si l'utilisateur est connecté.

src/middleware/render.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module.exports = async function(req, res, next) {
33
const nativeRender = res.render;
44
res.render = (view, data) => {
55
// La fonction render classique requiert l'objet `res` comme objet `this`.
6-
nativeRender.call(res, view, {...data, user: req.user});
6+
nativeRender.call(res, view, {...data, user: req.user, vapidPublic: process.env.PUBLIC_VAPID_KEY});
77
};
88
next();
99
};

src/routes/api.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const {Router} = require('express');
22
const {join} = require('path');
33
const multer = require('multer');
44
const upload = multer({dest: 'uploads/'});
5+
const webpush = require('web-push');
56

67
const router = Router();
78

@@ -42,6 +43,24 @@ router.post('/tweets/add', upload.single('image'), async (req, res) => {
4243
parentTweet.replies.push(tweet.id);
4344
await parentTweet.save();
4445
}
46+
47+
const {followers} = req.user;
48+
console.log(req.user);
49+
if (followers) {
50+
const [subs] = await req.app.db.connection.query('SELECT subscription FROM Subscription WHERE user_id IN (?)', [followers]);
51+
console.log(subs);
52+
const notification = {
53+
title: `Nouveau tweet de ${(req.user.displayName || '@' + req.user.username)}`,
54+
body: content,
55+
icon: process.env.APP_URL + `/public/${req.user.avatarId || 'default_avatar'}.jpg`,
56+
image: process.env.APP_URL + `/public/${imageId}.jpg`,
57+
url: process.env.APP_URL + `/tweet/${tweet.id}`
58+
};
59+
for (const sub of subs) {
60+
webpush.sendNotification(sub.subscription, JSON.stringify(notification)).catch(console.error);
61+
}
62+
}
63+
4564
return res.redirect(`/tweet/${tweet.id}`);
4665
});
4766

@@ -236,4 +255,10 @@ router.get('/unfollow/:id', async (req, res) => {
236255
return res.send({unfollowed: true});
237256
});
238257

258+
router.post('/subscription', async (req, res) => {
259+
const subscription = req.body;
260+
await req.app.db.connection.query('INSERT INTO Subscription VALUES (?, ?)', [req.user.id, JSON.stringify(subscription)]);
261+
res.sendStatus(201);
262+
});
263+
239264
module.exports = router;

src/sw.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
const staticCacheName = 'twitter.paulleflon.fr';
2+
3+
self.addEventListener('push', (e) => {
4+
const data = e.data.json();
5+
console.log(e);
6+
self.registration.showNotification(data.title, {
7+
body: data.body,
8+
icon: data.icon,
9+
image: data.image,
10+
data: {
11+
url: data.url
12+
}
13+
});
14+
});
15+
16+
self.addEventListener('notificationclick', e => {
17+
console.log(e);
18+
e.notification.close();
19+
e.waitUntil(clients.matchAll({type: 'window', includeUncontrolled: true}).then(clientList => {
20+
for (const client of clientList) {
21+
if (client.url === e.notification.data.url && 'focus' in client) {
22+
return client.focus();
23+
}
24+
}
25+
if (clients.openWindow) {
26+
return clients.openWindow(e.notification.data.url);
27+
}
28+
}));
29+
});
30+
31+
self.addEventListener('install', function(event) {
32+
event.waitUntil(
33+
caches.open(staticCacheName)
34+
.then(function(cache) {
35+
return cache.addAll([
36+
'/',
37+
]);
38+
})
39+
);
40+
});
41+
42+
self.addEventListener('fetch', function(event) {
43+
event.respondWith(
44+
caches.match(event.request)
45+
.then(function(response) {
46+
return response || fetch(event.request);
47+
})
48+
);
49+
});

0 commit comments

Comments
 (0)