feat: ajout système de notifications Web Push et prompt installation PWA iOS
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
|
||||||
@@ -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} didn’t 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
@@ -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');
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user