feat: ajout système de notifications Web Push et prompt installation PWA iOS

This commit is contained in:
2026-05-25 21:12:05 +02:00
parent 7682b90557
commit 86b86e9037
12 changed files with 5102 additions and 4 deletions
+4 -4
View File
@@ -201,10 +201,10 @@ Valider la faisabilité technique : 2-4 clients, PTT basique, latence < 150ms, m
- [ ] Tests : routing multi-canaux, canaux partagés - Phase 3 - [ ] Tests : routing multi-canaux, canaux partagés - Phase 3
### 2.4 Notifications ### 2.4 Notifications
- [ ] Web Push : appels privés - [x] Web Push : appels privés (infrastructure prête)
- [ ] Service Worker : gestion notifications - [x] Service Worker : gestion notifications
- [ ] iOS : message onboarding "Installer sur écran d'accueil" - [x] iOS : message onboarding "Installer sur écran d'accueil"
- [ ] Permissions notification au premier lancement - [x] Permissions notification au premier lancement
--- ---
+1
View File
@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
+100
View File
@@ -0,0 +1,100 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-290dd570'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.pjhah2a023"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
workbox.registerRoute(/^https:\/\/.*\.livekit\.cloud\/.*/i, new workbox.NetworkFirst({
"cacheName": "livekit-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 86400
})]
}), 'GET');
}));
//# sourceMappingURL=sw.js.map
//# sourceMappingURL=sw.js.map
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+74
View File
@@ -0,0 +1,74 @@
// Service Worker personnalisé pour PTT Live
// Gère les notifications push pour les appels privés
self.addEventListener('install', (event) => {
console.log('Service Worker: Installation');
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
console.log('Service Worker: Activation');
event.waitUntil(self.clients.claim());
});
// Écouter les notifications push du serveur
self.addEventListener('push', (event) => {
console.log('Service Worker: Push reçu');
let data = {
title: 'PTT Live',
body: 'Nouveau message',
icon: '/pwa-192x192.png',
badge: '/badge-72x72.png'
};
if (event.data) {
try {
data = event.data.json();
} catch (error) {
console.error('Erreur parsing push data:', error);
}
}
const options = {
body: data.body,
icon: data.icon || '/pwa-192x192.png',
badge: data.badge || '/badge-72x72.png',
vibrate: [200, 100, 200],
tag: data.tag || 'ptt-notification',
requireInteraction: data.requireInteraction || false,
data: data.data || {}
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// Gérer les clics sur les notifications
self.addEventListener('notificationclick', (event) => {
console.log('Service Worker: Notification cliquée');
event.notification.close();
// Ouvrir l'application ou focus si déjà ouverte
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Si une fenêtre est déjà ouverte, la focus
for (const client of clientList) {
if (client.url.includes(self.registration.scope) && 'focus' in client) {
return client.focus();
}
}
// Sinon ouvrir une nouvelle fenêtre
if (clients.openWindow) {
return clients.openWindow('/');
}
})
);
});
// Gérer la fermeture des notifications
self.addEventListener('notificationclose', (event) => {
console.log('Service Worker: Notification fermée');
});
+18
View File
@@ -1,9 +1,11 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import useLiveKit from './hooks/useLiveKit'; import useLiveKit from './hooks/useLiveKit';
import usePush from './hooks/usePush';
import PTTButton from './components/PTTButton'; import PTTButton from './components/PTTButton';
import UserList from './components/UserList'; import UserList from './components/UserList';
import GroupSelector from './components/GroupSelector'; import GroupSelector from './components/GroupSelector';
import Settings from './components/Settings'; import Settings from './components/Settings';
import PWAInstallPrompt from './components/PWAInstallPrompt';
import './App.css'; import './App.css';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -29,6 +31,13 @@ function App() {
toggleParticipantMute toggleParticipantMute
} = useLiveKit(); } = useLiveKit();
const {
isSupported: isPushSupported,
isPermissionGranted: isPushGranted,
requestPermission: requestPushPermission,
showNotification
} = usePush();
// Charger configuration au démarrage // Charger configuration au démarrage
useEffect(() => { useEffect(() => {
fetch(`${API_URL}/config`) fetch(`${API_URL}/config`)
@@ -60,6 +69,12 @@ function App() {
setError(null); setError(null);
try { try {
// Demander permission notifications au premier lancement
if (isPushSupported && !isPushGranted) {
console.log('Demande permission notifications...');
await requestPushPermission();
}
// IMPORTANT iOS : Demander permission microphone AVANT tout // IMPORTANT iOS : Demander permission microphone AVANT tout
console.log('🎤 Demande permission microphone...'); console.log('🎤 Demande permission microphone...');
try { try {
@@ -268,6 +283,9 @@ function App() {
{/* Modal de paramètres */} {/* Modal de paramètres */}
<Settings isOpen={showSettings} onClose={() => setShowSettings(false)} /> <Settings isOpen={showSettings} onClose={() => setShowSettings(false)} />
{/* Prompt installation PWA (iOS) */}
<PWAInstallPrompt />
</div> </div>
); );
} }
+117
View File
@@ -0,0 +1,117 @@
.pwa-prompt-overlay {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1001;
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
.pwa-prompt {
background: var(--bg-secondary);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
max-height: 80vh;
overflow-y: auto;
}
.pwa-prompt-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-lg);
border-bottom: 1px solid var(--border-color);
}
.pwa-prompt-header h3 {
margin: 0;
font-size: 1.2rem;
color: var(--text-primary);
}
.pwa-prompt-close {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0.5rem;
border-radius: 8px;
transition: all 0.2s;
}
.pwa-prompt-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.pwa-prompt-content {
padding: var(--spacing-lg);
}
.pwa-prompt-content > p {
margin: 0 0 var(--spacing-lg) 0;
color: var(--text-secondary);
line-height: 1.6;
}
.pwa-prompt-steps {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
}
.pwa-prompt-step {
display: flex;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: var(--bg-hover);
border-radius: 8px;
}
.step-number {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--primary-color);
color: white;
border-radius: 50%;
font-weight: 600;
flex-shrink: 0;
}
.pwa-prompt-step p {
flex: 1;
margin: 0;
color: var(--text-primary);
font-size: 0.95rem;
}
.pwa-prompt-step svg {
flex-shrink: 0;
color: var(--primary-color);
}
.pwa-prompt-footer {
padding: var(--spacing-lg);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: center;
}
.pwa-prompt-footer .btn-primary {
width: 100%;
max-width: 300px;
}
@@ -0,0 +1,91 @@
import { useState, useEffect } from 'react';
import './PWAInstallPrompt.css';
/**
* Composant pour afficher un message d'onboarding PWA
* Spécialement pour iOS qui nécessite l'installation manuelle
*/
export default function PWAInstallPrompt() {
const [showPrompt, setShowPrompt] = useState(false);
const [isIOS, setIsIOS] = useState(false);
const [isStandalone, setIsStandalone] = useState(false);
useEffect(() => {
// Détecter iOS
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
setIsIOS(iOS);
// Détecter si déjà en mode standalone (installé)
const standalone = window.matchMedia('(display-mode: standalone)').matches
|| window.navigator.standalone
|| document.referrer.includes('android-app://');
setIsStandalone(standalone);
// Vérifier si l'utilisateur a déjà vu le prompt
const hasSeenPrompt = localStorage.getItem('pwa-install-prompt-seen');
// Afficher le prompt si iOS, pas installé, et jamais vu
if (iOS && !standalone && !hasSeenPrompt) {
// Afficher après 3 secondes pour ne pas être intrusif
setTimeout(() => {
setShowPrompt(true);
}, 3000);
}
}, []);
const handleDismiss = () => {
setShowPrompt(false);
localStorage.setItem('pwa-install-prompt-seen', 'true');
};
if (!showPrompt || !isIOS || isStandalone) {
return null;
}
return (
<div className="pwa-prompt-overlay">
<div className="pwa-prompt">
<div className="pwa-prompt-header">
<h3>Installation requise pour les notifications</h3>
<button className="pwa-prompt-close" onClick={handleDismiss}>
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z"/>
</svg>
</button>
</div>
<div className="pwa-prompt-content">
<p>
Pour recevoir les notifications d'appels, vous devez installer l'application sur votre écran d'accueil.
</p>
<div className="pwa-prompt-steps">
<div className="pwa-prompt-step">
<div className="step-number">1</div>
<p>Appuyez sur le bouton <strong>Partager</strong></p>
<svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
<path d="M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z"/>
</svg>
</div>
<div className="pwa-prompt-step">
<div className="step-number">2</div>
<p>Sélectionnez <strong>Sur l'écran d'accueil</strong></p>
</div>
<div className="pwa-prompt-step">
<div className="step-number">3</div>
<p>Tapez <strong>Ajouter</strong></p>
</div>
</div>
</div>
<div className="pwa-prompt-footer">
<button className="btn-primary" onClick={handleDismiss}>
J'ai compris
</button>
</div>
</div>
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { useState, useEffect, useCallback } from 'react';
/**
* Hook pour gérer les notifications Web Push
* Utilisé pour les appels privés et notifications de groupe
*/
export default function usePush() {
const [isSupported, setIsSupported] = useState(false);
const [isPermissionGranted, setIsPermissionGranted] = useState(false);
const [subscription, setSubscription] = useState(null);
useEffect(() => {
// Vérifier si les notifications sont supportées
const supported = 'Notification' in window && 'serviceWorker' in navigator;
setIsSupported(supported);
if (supported) {
// Vérifier la permission actuelle
setIsPermissionGranted(Notification.permission === 'granted');
}
}, []);
/**
* Demander la permission pour les notifications
*/
const requestPermission = useCallback(async () => {
if (!isSupported) {
console.warn('Notifications non supportées sur ce navigateur');
return false;
}
try {
const permission = await Notification.requestPermission();
const granted = permission === 'granted';
setIsPermissionGranted(granted);
if (granted) {
console.log('Permission notifications accordée');
} else {
console.warn('Permission notifications refusée');
}
return granted;
} catch (error) {
console.error('Erreur demande permission notifications:', error);
return false;
}
}, [isSupported]);
/**
* S'abonner aux notifications push (via service worker)
*/
const subscribeToPush = useCallback(async () => {
if (!isSupported || !isPermissionGranted) {
console.warn('Impossible de s\'abonner : permission non accordée');
return null;
}
try {
// Attendre que le service worker soit prêt
const registration = await navigator.serviceWorker.ready;
// Créer l'abonnement push
const sub = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(
// TODO: Remplacer par la vraie clé VAPID du serveur
import.meta.env.VITE_VAPID_PUBLIC_KEY || ''
)
});
console.log('Abonnement push créé:', sub);
setSubscription(sub);
return sub;
} catch (error) {
console.error('Erreur abonnement push:', error);
return null;
}
}, [isSupported, isPermissionGranted]);
/**
* Se désabonner des notifications push
*/
const unsubscribeFromPush = useCallback(async () => {
if (!subscription) {
return true;
}
try {
await subscription.unsubscribe();
console.log('Désabonnement push réussi');
setSubscription(null);
return true;
} catch (error) {
console.error('Erreur désabonnement push:', error);
return false;
}
}, [subscription]);
/**
* Envoyer une notification locale (sans push serveur)
*/
const showNotification = useCallback(async (title, options = {}) => {
if (!isSupported || !isPermissionGranted) {
console.warn('Impossible d\'afficher la notification : permission non accordée');
return;
}
try {
const registration = await navigator.serviceWorker.ready;
await registration.showNotification(title, {
icon: '/icon-192x192.png',
badge: '/badge-72x72.png',
vibrate: [200, 100, 200],
...options
});
} catch (error) {
console.error('Erreur affichage notification:', error);
}
}, [isSupported, isPermissionGranted]);
return {
isSupported,
isPermissionGranted,
subscription,
requestPermission,
subscribeToPush,
unsubscribeFromPush,
showNotification
};
}
/**
* Convertir une clé VAPID base64 en Uint8Array
*/
function urlBase64ToUint8Array(base64String) {
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(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
+4
View File
@@ -9,6 +9,10 @@ export default defineConfig({
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'], includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
injectRegister: 'auto',
devOptions: {
enabled: true
},
manifest: { manifest: {
name: 'PTT Live', name: 'PTT Live',
short_name: 'PTT Live', short_name: 'PTT Live',