[call-me] - #3 add web push notifications
This commit is contained in:
@@ -59,3 +59,11 @@ API_KEY_SECRET=call_me_api_key_secret # change me
|
||||
# RANDOM_IMAGE_URL='https://api.unsplash.com/photos/random?query=nature&orientation=landscape&client_id=YOUR-ACCESS-KEY';
|
||||
|
||||
RANDOM_IMAGE_URL=''
|
||||
|
||||
# Push Notifications (Web Push / VAPID)
|
||||
# Generate VAPID keys with: npm run generate-vapid-keys
|
||||
|
||||
PUSH_ENABLED=false # true or false
|
||||
PUSH_VAPID_PUBLIC_KEY=''
|
||||
PUSH_VAPID_PRIVATE_KEY=''
|
||||
PUSH_VAPID_EMAIL='mailto:admin@example.com'
|
||||
|
||||
@@ -58,6 +58,20 @@ paths:
|
||||
'403':
|
||||
description: 'Unauthorized!'
|
||||
|
||||
/vapidPublicKey:
|
||||
get:
|
||||
tags:
|
||||
- 'push'
|
||||
summary: 'Get VAPID public key'
|
||||
description: 'Get the VAPID public key for Web Push subscription'
|
||||
produces:
|
||||
- 'application/json'
|
||||
responses:
|
||||
'200':
|
||||
description: 'VAPID public key'
|
||||
schema:
|
||||
$ref: '#/definitions/VapidPublicKeyResponse'
|
||||
|
||||
securityDefinitions:
|
||||
secretApiKey:
|
||||
type: 'apiKey'
|
||||
@@ -79,3 +93,13 @@ definitions:
|
||||
connected:
|
||||
type: array
|
||||
example: ['https://your.domain/join?user=call-me&call=miro']
|
||||
|
||||
VapidPublicKeyResponse:
|
||||
type: 'object'
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
example: true
|
||||
vapidPublicKey:
|
||||
type: string
|
||||
example: 'BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkPs-aP9IKV5O1BamVfGPpxtSkgUc_bUz41MXd8Aww'
|
||||
|
||||
+12
-1
@@ -152,7 +152,9 @@
|
||||
"clearAll": "مسح الكل",
|
||||
"noCamerasFound": "لم يتم العثور على كاميرات",
|
||||
"noMicrophonesFound": "لم يتم العثور على ميكروفونات",
|
||||
"noSpeakersFound": "لم يتم العثور على مكبرات صوت"
|
||||
"noSpeakersFound": "لم يتم العثور على مكبرات صوت",
|
||||
"pushNotifications": "إشعارات الدفع",
|
||||
"testPush": "اختبار"
|
||||
},
|
||||
"chat": {
|
||||
"addEmoji": "إضافة إيموجي",
|
||||
@@ -211,5 +213,14 @@
|
||||
"sentFileLabel": " أرسل ملفًا: ",
|
||||
"sending": "جارٍ الإرسال: __filename__",
|
||||
"receiving": "جارٍ الاستلام: __filename__"
|
||||
},
|
||||
"push": {
|
||||
"incomingCallTitle": "مكالمة واردة",
|
||||
"incomingCallBody": "__caller__ يتصل بك",
|
||||
"notificationSent": "__username__ غير متصل. تم إرسال إشعار — في انتظار اتصاله...",
|
||||
"enabled": "تم تفعيل الإشعارات",
|
||||
"disabled": "تم تعطيل الإشعارات",
|
||||
"permissionDenied": "تم رفض إذن الإشعارات",
|
||||
"testSent": "تم إرسال إشعار تجريبي"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -152,7 +152,9 @@
|
||||
"clearAll": "Alles Löschen",
|
||||
"noCamerasFound": "Keine Kameras gefunden",
|
||||
"noMicrophonesFound": "Keine Mikrofone gefunden",
|
||||
"noSpeakersFound": "Keine Lautsprecher gefunden"
|
||||
"noSpeakersFound": "Keine Lautsprecher gefunden",
|
||||
"pushNotifications": "Push-Benachrichtigungen",
|
||||
"testPush": "Test"
|
||||
},
|
||||
"chat": {
|
||||
"addEmoji": "Emoji hinzufügen",
|
||||
@@ -211,5 +213,14 @@
|
||||
"sentFileLabel": " hat eine Datei gesendet: ",
|
||||
"sending": "Senden: __filename__",
|
||||
"receiving": "Empfangen: __filename__"
|
||||
},
|
||||
"push": {
|
||||
"incomingCallTitle": "Eingehender Anruf",
|
||||
"incomingCallBody": "__caller__ ruft dich an",
|
||||
"notificationSent": "__username__ ist offline. Eine Benachrichtigung wurde gesendet — warten auf Verbindung...",
|
||||
"enabled": "Push-Benachrichtigungen aktiviert",
|
||||
"disabled": "Push-Benachrichtigungen deaktiviert",
|
||||
"permissionDenied": "Benachrichtigungsberechtigung verweigert",
|
||||
"testSent": "Testbenachrichtigung gesendet"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -153,7 +153,9 @@
|
||||
"clearAll": "Clear All",
|
||||
"noCamerasFound": "No cameras found",
|
||||
"noMicrophonesFound": "No microphones found",
|
||||
"noSpeakersFound": "No speakers found"
|
||||
"noSpeakersFound": "No speakers found",
|
||||
"pushNotifications": "Push Notifications",
|
||||
"testPush": "Test"
|
||||
},
|
||||
"chat": {
|
||||
"addEmoji": "Add emoji",
|
||||
@@ -212,5 +214,14 @@
|
||||
"sentFileLabel": " sent file: ",
|
||||
"sending": "Sending: __filename__",
|
||||
"receiving": "Receiving: __filename__"
|
||||
},
|
||||
"push": {
|
||||
"incomingCallTitle": "Incoming Call",
|
||||
"incomingCallBody": "__caller__ is calling you",
|
||||
"notificationSent": "__username__ is offline. A notification was sent \u2014 waiting for them to come online...",
|
||||
"enabled": "Push notifications enabled",
|
||||
"disabled": "Push notifications disabled",
|
||||
"permissionDenied": "Notification permission denied by browser",
|
||||
"testSent": "Test notification sent"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -152,7 +152,9 @@
|
||||
"clearAll": "Borrar Todo",
|
||||
"noCamerasFound": "No se encontraron cámaras",
|
||||
"noMicrophonesFound": "No se encontraron micrófonos",
|
||||
"noSpeakersFound": "No se encontraron altavoces"
|
||||
"noSpeakersFound": "No se encontraron altavoces",
|
||||
"pushNotifications": "Notificaciones push",
|
||||
"testPush": "Probar"
|
||||
},
|
||||
"chat": {
|
||||
"addEmoji": "Agregar emoji",
|
||||
@@ -211,5 +213,14 @@
|
||||
"sentFileLabel": " envió el archivo: ",
|
||||
"sending": "Enviando: __filename__",
|
||||
"receiving": "Recibiendo: __filename__"
|
||||
},
|
||||
"push": {
|
||||
"incomingCallTitle": "Llamada entrante",
|
||||
"incomingCallBody": "__caller__ te está llamando",
|
||||
"notificationSent": "__username__ est\u00e1 desconectado. Se envi\u00f3 una notificaci\u00f3n \u2014 esperando a que se conecte...",
|
||||
"enabled": "Notificaciones push activadas",
|
||||
"disabled": "Notificaciones push desactivadas",
|
||||
"permissionDenied": "Permiso de notificaciones denegado",
|
||||
"testSent": "Notificación de prueba enviada"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -152,7 +152,9 @@
|
||||
"clearAll": "Tout Effacer",
|
||||
"noCamerasFound": "Aucune caméra trouvée",
|
||||
"noMicrophonesFound": "Aucun microphone trouvé",
|
||||
"noSpeakersFound": "Aucun haut-parleur trouvé"
|
||||
"noSpeakersFound": "Aucun haut-parleur trouvé",
|
||||
"pushNotifications": "Notifications push",
|
||||
"testPush": "Tester"
|
||||
},
|
||||
"chat": {
|
||||
"addEmoji": "Ajouter un emoji",
|
||||
@@ -211,5 +213,14 @@
|
||||
"sentFileLabel": " a envoyé le fichier : ",
|
||||
"sending": "Envoi : __filename__",
|
||||
"receiving": "Réception : __filename__"
|
||||
},
|
||||
"push": {
|
||||
"incomingCallTitle": "Appel entrant",
|
||||
"incomingCallBody": "__caller__ vous appelle",
|
||||
"notificationSent": "__username__ est hors ligne. Une notification a \u00e9t\u00e9 envoy\u00e9e \u2014 en attente de connexion...",
|
||||
"enabled": "Notifications push activ\u00e9es",
|
||||
"disabled": "Notifications push d\u00e9sactiv\u00e9es",
|
||||
"permissionDenied": "Permission de notification refusée",
|
||||
"testSent": "Notification de test envoyée"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -152,7 +152,9 @@
|
||||
"clearAll": "सभी साफ़ करें",
|
||||
"noCamerasFound": "कोई कैमरा नहीं मिला",
|
||||
"noMicrophonesFound": "कोई माइक्रोफ़ोन नहीं मिला",
|
||||
"noSpeakersFound": "कोई स्पीकर नहीं मिला"
|
||||
"noSpeakersFound": "कोई स्पीकर नहीं मिला",
|
||||
"pushNotifications": "पुश सूचनाएं",
|
||||
"testPush": "परीक्षण"
|
||||
},
|
||||
"chat": {
|
||||
"addEmoji": "इमोजी जोड़ें",
|
||||
@@ -211,5 +213,14 @@
|
||||
"sentFileLabel": " ने फ़ाइल भेजी: ",
|
||||
"sending": "भेजा जा रहा है: __filename__",
|
||||
"receiving": "प्राप्त किया जा रहा है: __filename__"
|
||||
},
|
||||
"push": {
|
||||
"incomingCallTitle": "आने वाली कॉल",
|
||||
"incomingCallBody": "__caller__ आपको कॉल कर रहा है",
|
||||
"notificationSent": "__username__ ऑफ़लाइन है। एक सूचना भेजी गई — उनके ऑनलाइन आने की प्रतीक्षा...",
|
||||
"enabled": "पुश सूचनाएं सक्षम",
|
||||
"disabled": "पुश सूचनाएं अक्षम",
|
||||
"permissionDenied": "सूचना अनुमति अस्वीकृत",
|
||||
"testSent": "परीक्षण सूचना भेजी गई"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -152,7 +152,9 @@
|
||||
"clearAll": "Obriši sve",
|
||||
"noCamerasFound": "Nisu pronađene kamere",
|
||||
"noMicrophonesFound": "Nisu pronađeni mikrofoni",
|
||||
"noSpeakersFound": "Nisu pronađeni zvučnici"
|
||||
"noSpeakersFound": "Nisu pronađeni zvučnici",
|
||||
"pushNotifications": "Push obavijesti",
|
||||
"testPush": "Testiraj"
|
||||
},
|
||||
"chat": {
|
||||
"addEmoji": "Dodaj emoji",
|
||||
@@ -211,5 +213,14 @@
|
||||
"sentFileLabel": " je poslao/la datoteku: ",
|
||||
"sending": "Slanje: __filename__",
|
||||
"receiving": "Primanje: __filename__"
|
||||
},
|
||||
"push": {
|
||||
"incomingCallTitle": "Dolazni poziv",
|
||||
"incomingCallBody": "__caller__ vas zove",
|
||||
"notificationSent": "__username__ je offline. Obavijest je poslana \u2014 \u010dekanje na povezivanje...",
|
||||
"enabled": "Push obavijesti uklju\u010dene",
|
||||
"disabled": "Push obavijesti isklju\u010dene",
|
||||
"permissionDenied": "Dozvola za obavijesti odbijena",
|
||||
"testSent": "Testna obavijest poslana"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -152,7 +152,9 @@
|
||||
"clearAll": "Cancella Tutto",
|
||||
"noCamerasFound": "Nessuna fotocamera trovata",
|
||||
"noMicrophonesFound": "Nessun microfono trovato",
|
||||
"noSpeakersFound": "Nessun altoparlante trovato"
|
||||
"noSpeakersFound": "Nessun altoparlante trovato",
|
||||
"pushNotifications": "Notifiche push",
|
||||
"testPush": "Prova"
|
||||
},
|
||||
"chat": {
|
||||
"addEmoji": "Aggiungi emoji",
|
||||
@@ -211,5 +213,14 @@
|
||||
"sentFileLabel": " ha inviato il file: ",
|
||||
"sending": "Invio: __filename__",
|
||||
"receiving": "Ricezione: __filename__"
|
||||
},
|
||||
"push": {
|
||||
"incomingCallTitle": "Chiamata in arrivo",
|
||||
"incomingCallBody": "__caller__ ti sta chiamando",
|
||||
"notificationSent": "__username__ \u00e8 offline. \u00c8 stata inviata una notifica \u2014 in attesa che si connetta...",
|
||||
"enabled": "Notifiche push attivate",
|
||||
"disabled": "Notifiche push disattivate",
|
||||
"permissionDenied": "Autorizzazione notifiche negata",
|
||||
"testSent": "Notifica di prova inviata"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -152,7 +152,9 @@
|
||||
"clearAll": "すべてクリア",
|
||||
"noCamerasFound": "カメラが見つかりません",
|
||||
"noMicrophonesFound": "マイクが見つかりません",
|
||||
"noSpeakersFound": "スピーカーが見つかりません"
|
||||
"noSpeakersFound": "スピーカーが見つかりません",
|
||||
"pushNotifications": "プッシュ通知",
|
||||
"testPush": "テスト"
|
||||
},
|
||||
"chat": {
|
||||
"addEmoji": "絵文字を追加",
|
||||
@@ -211,5 +213,14 @@
|
||||
"sentFileLabel": " がファイルを送信: ",
|
||||
"sending": "送信中: __filename__",
|
||||
"receiving": "受信中: __filename__"
|
||||
},
|
||||
"push": {
|
||||
"incomingCallTitle": "着信",
|
||||
"incomingCallBody": "__caller__ から着信があります",
|
||||
"notificationSent": "__username__ はオフラインです。通知を送信しました — オンラインになるのを待っています...",
|
||||
"enabled": "プッシュ通知が有効になりました",
|
||||
"disabled": "プッシュ通知が無効になりました",
|
||||
"permissionDenied": "通知の許可が拒否されました",
|
||||
"testSent": "テスト通知を送信しました"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -152,7 +152,9 @@
|
||||
"clearAll": "Limpar tudo",
|
||||
"noCamerasFound": "Nenhuma câmera encontrada",
|
||||
"noMicrophonesFound": "Nenhum microfone encontrado",
|
||||
"noSpeakersFound": "Nenhum alto-falante encontrado"
|
||||
"noSpeakersFound": "Nenhum alto-falante encontrado",
|
||||
"pushNotifications": "Notificações push",
|
||||
"testPush": "Testar"
|
||||
},
|
||||
"chat": {
|
||||
"addEmoji": "Adicionar emoji",
|
||||
@@ -211,5 +213,14 @@
|
||||
"sentFileLabel": " enviou o arquivo: ",
|
||||
"sending": "Enviando: __filename__",
|
||||
"receiving": "Recebendo: __filename__"
|
||||
},
|
||||
"push": {
|
||||
"incomingCallTitle": "Chamada recebida",
|
||||
"incomingCallBody": "__caller__ está ligando para você",
|
||||
"notificationSent": "__username__ está offline. Uma notificação foi enviada — aguardando conexão...",
|
||||
"enabled": "Notificações push ativadas",
|
||||
"disabled": "Notificações push desativadas",
|
||||
"permissionDenied": "Permissão de notificação negada",
|
||||
"testSent": "Notificação de teste enviada"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -152,7 +152,9 @@
|
||||
"clearAll": "Очистить всё",
|
||||
"noCamerasFound": "Камеры не найдены",
|
||||
"noMicrophonesFound": "Микрофоны не найдены",
|
||||
"noSpeakersFound": "Динамики не найдены"
|
||||
"noSpeakersFound": "Динамики не найдены",
|
||||
"pushNotifications": "Пуш-уведомления",
|
||||
"testPush": "Тест"
|
||||
},
|
||||
"chat": {
|
||||
"addEmoji": "Добавить эмодзи",
|
||||
@@ -211,5 +213,14 @@
|
||||
"sentFileLabel": " отправил файл: ",
|
||||
"sending": "Отправка: __filename__",
|
||||
"receiving": "Получение: __filename__"
|
||||
},
|
||||
"push": {
|
||||
"incomingCallTitle": "Входящий звонок",
|
||||
"incomingCallBody": "__caller__ звонит вам",
|
||||
"notificationSent": "__username__ не в сети. Уведомление отправлено — ожидание подключения...",
|
||||
"enabled": "Пуш-уведомления включены",
|
||||
"disabled": "Пуш-уведомления отключены",
|
||||
"permissionDenied": "Разрешение на уведомления отклонено",
|
||||
"testSent": "Тестовое уведомление отправлено"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -152,7 +152,9 @@
|
||||
"clearAll": "Obriši sve",
|
||||
"noCamerasFound": "Nisu pronađene kamere",
|
||||
"noMicrophonesFound": "Nisu pronađeni mikrofoni",
|
||||
"noSpeakersFound": "Nisu pronađeni zvučnici"
|
||||
"noSpeakersFound": "Nisu pronađeni zvučnici",
|
||||
"pushNotifications": "Пуш обавештења",
|
||||
"testPush": "Тест"
|
||||
},
|
||||
"chat": {
|
||||
"addEmoji": "Dodaj emoji",
|
||||
@@ -211,5 +213,14 @@
|
||||
"sentFileLabel": " poslao fajl: ",
|
||||
"sending": "Slanje: __filename__",
|
||||
"receiving": "Prijem: __filename__"
|
||||
},
|
||||
"push": {
|
||||
"incomingCallTitle": "Долазни позив",
|
||||
"incomingCallBody": "__caller__ вас зове",
|
||||
"notificationSent": "__username__ је офлајн. Обавештење је послато — чекање на повезивање...",
|
||||
"enabled": "Пуш обавештења укључена",
|
||||
"disabled": "Пуш обавештења искључена",
|
||||
"permissionDenied": "Дозвола за обавештења одбијена",
|
||||
"testSent": "Тест обавештење послато"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -152,7 +152,9 @@
|
||||
"clearAll": "全部清除",
|
||||
"noCamerasFound": "未找到摄像头",
|
||||
"noMicrophonesFound": "未找到麦克风",
|
||||
"noSpeakersFound": "未找到扬声器"
|
||||
"noSpeakersFound": "未找到扬声器",
|
||||
"pushNotifications": "推送通知",
|
||||
"testPush": "测试"
|
||||
},
|
||||
"chat": {
|
||||
"addEmoji": "添加表情",
|
||||
@@ -211,5 +213,14 @@
|
||||
"sentFileLabel": " 发送了文件: ",
|
||||
"sending": "发送中: __filename__",
|
||||
"receiving": "接收中: __filename__"
|
||||
},
|
||||
"push": {
|
||||
"incomingCallTitle": "来电",
|
||||
"incomingCallBody": "__caller__ 正在呼叫您",
|
||||
"notificationSent": "__username__ 离线。已发送通知 — 等待上线...",
|
||||
"enabled": "推送通知已开启",
|
||||
"disabled": "推送通知已关闭",
|
||||
"permissionDenied": "通知权限被拒绝",
|
||||
"testSent": "测试通知已发送"
|
||||
}
|
||||
}
|
||||
|
||||
+179
-2
@@ -13,6 +13,7 @@ const helmet = require('helmet');
|
||||
const path = require('path');
|
||||
const yaml = require('js-yaml');
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const webpush = require('web-push');
|
||||
const packageJson = require('../package.json');
|
||||
|
||||
// Logs
|
||||
@@ -51,6 +52,9 @@ const users = new Map();
|
||||
// Map to store user media status (video/audio enabled/disabled)
|
||||
const userMediaStatus = new Map();
|
||||
|
||||
// Map to store push subscriptions (username -> PushSubscription[])
|
||||
const pushSubscriptions = new Map();
|
||||
|
||||
// Configuration settings
|
||||
const config = {
|
||||
iceServers: [],
|
||||
@@ -63,6 +67,10 @@ const config = {
|
||||
hostPasswordEnabled: process.env.HOST_PASSWORD_ENABLED === 'true',
|
||||
hostPassword: process.env.HOST_PASSWORD || '',
|
||||
apiKeySecret: process.env.API_KEY_SECRET,
|
||||
pushEnabled: process.env.PUSH_ENABLED === 'true',
|
||||
pushVapidPublicKey: process.env.PUSH_VAPID_PUBLIC_KEY || '',
|
||||
pushVapidPrivateKey: process.env.PUSH_VAPID_PRIVATE_KEY || '',
|
||||
pushVapidEmail: process.env.PUSH_VAPID_EMAIL || 'mailto:admin@example.com',
|
||||
randomImageUrl: process.env.RANDOM_IMAGE_URL || '',
|
||||
apiBasePath: '/api/v1',
|
||||
swaggerDocument: yaml.load(fs.readFileSync(path.join(__dirname, '/api/swagger.yaml'), 'utf8')),
|
||||
@@ -85,6 +93,15 @@ if (config.turnServerEnabled && config.turnServerUrl && config.turnServerUsernam
|
||||
});
|
||||
}
|
||||
|
||||
// Configure Web Push if enabled
|
||||
if (config.pushEnabled && config.pushVapidPublicKey && config.pushVapidPrivateKey) {
|
||||
webpush.setVapidDetails(config.pushVapidEmail, config.pushVapidPublicKey, config.pushVapidPrivateKey);
|
||||
log.info('Web Push', { enabled: true });
|
||||
} else if (config.pushEnabled) {
|
||||
log.warn('Web Push', { enabled: false, reason: 'VAPID keys not configured. Run: npm run generate-vapid-keys' });
|
||||
config.pushEnabled = false;
|
||||
}
|
||||
|
||||
const ngrokEnabled = process.env.NGROK_ENABLED === 'true';
|
||||
const ngrokAuthToken = process.env.NGROK_AUTH_TOKEN;
|
||||
|
||||
@@ -330,6 +347,29 @@ app.get(`${config.apiBasePath}/users`, (req, res) => {
|
||||
return res.json({ users });
|
||||
});
|
||||
|
||||
// Get VAPID public key for push subscription
|
||||
app.get(`${config.apiBasePath}/vapidPublicKey`, (req, res) => {
|
||||
if (!config.pushEnabled) {
|
||||
return res.json({ enabled: false });
|
||||
}
|
||||
return res.json({ enabled: true, vapidPublicKey: config.pushVapidPublicKey });
|
||||
});
|
||||
|
||||
// Handle push subscription update via REST (for service worker pushsubscriptionchange)
|
||||
app.post(`${config.apiBasePath}/pushSubscription`, express.json(), (req, res) => {
|
||||
if (!config.pushEnabled) {
|
||||
return res.status(400).json({ error: 'Push notifications not enabled' });
|
||||
}
|
||||
const { subscription } = req.body;
|
||||
if (!subscription || !subscription.endpoint) {
|
||||
return res.status(400).json({ error: 'Invalid subscription' });
|
||||
}
|
||||
// We can't reliably map this to a username from REST alone,
|
||||
// but we store it for resubscription change events
|
||||
log.debug('Push subscription updated via REST');
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
// Check if Host password required
|
||||
app.get('/api/hostPassword', (req, res) => {
|
||||
const isPasswordRequired = config.hostPasswordEnabled;
|
||||
@@ -434,6 +474,15 @@ function handleConnection(socket) {
|
||||
handleChatMessage(data);
|
||||
log.debug('Chat message:', data);
|
||||
break;
|
||||
case 'pushSubscription':
|
||||
handlePushSubscription(data);
|
||||
break;
|
||||
case 'pushUnsubscribe':
|
||||
handlePushUnsubscribe(data);
|
||||
break;
|
||||
case 'testPush':
|
||||
handleTestPush();
|
||||
break;
|
||||
case 'pong':
|
||||
log.debug('Client response:', data.message);
|
||||
break;
|
||||
@@ -449,9 +498,76 @@ function handleConnection(socket) {
|
||||
type: 'ping',
|
||||
message: 'Hello Client!',
|
||||
iceServers: config.iceServers,
|
||||
pushEnabled: config.pushEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
// Function to handle push subscription registration
|
||||
function handlePushSubscription(data) {
|
||||
const { subscription } = data;
|
||||
const username = socket.username;
|
||||
|
||||
if (!config.pushEnabled || !username || !subscription || !subscription.endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store subscription (support multiple devices per user)
|
||||
const existing = pushSubscriptions.get(username) || [];
|
||||
// Replace if same endpoint exists, otherwise add
|
||||
const idx = existing.findIndex((s) => s.endpoint === subscription.endpoint);
|
||||
if (idx >= 0) {
|
||||
existing[idx] = subscription;
|
||||
} else {
|
||||
existing.push(subscription);
|
||||
}
|
||||
pushSubscriptions.set(username, existing);
|
||||
log.debug('Push subscription stored for', username, { devices: existing.length });
|
||||
}
|
||||
|
||||
// Function to handle push unsubscribe
|
||||
function handlePushUnsubscribe(data) {
|
||||
const { endpoint } = data;
|
||||
const username = socket.username;
|
||||
|
||||
if (!username || !endpoint) return;
|
||||
|
||||
const existing = pushSubscriptions.get(username) || [];
|
||||
const filtered = existing.filter((s) => s.endpoint !== endpoint);
|
||||
if (filtered.length > 0) {
|
||||
pushSubscriptions.set(username, filtered);
|
||||
} else {
|
||||
pushSubscriptions.delete(username);
|
||||
}
|
||||
log.debug('Push subscription removed for', username, { remaining: filtered.length });
|
||||
}
|
||||
|
||||
// Function to handle test push notification
|
||||
async function handleTestPush() {
|
||||
const username = socket.username;
|
||||
if (!config.pushEnabled || !username) return;
|
||||
|
||||
const subscriptions = pushSubscriptions.get(username);
|
||||
if (!subscriptions || subscriptions.length === 0) {
|
||||
log.debug('No push subscriptions for test push', username);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: 'testPush',
|
||||
title: 'Call-me',
|
||||
body: 'Push notifications are working!',
|
||||
});
|
||||
|
||||
for (const sub of subscriptions) {
|
||||
try {
|
||||
await webpush.sendNotification(sub, payload);
|
||||
log.debug('Test push sent to', username);
|
||||
} catch (err) {
|
||||
log.warn('Test push failed for', username, { statusCode: err.statusCode, message: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to handle user sign-in request
|
||||
function handleSignIn(data) {
|
||||
const { name } = data;
|
||||
@@ -509,8 +625,19 @@ function handleConnection(socket) {
|
||||
};
|
||||
sendMsgTo(recipientSocket, offerData);
|
||||
} else {
|
||||
log.warn(`Recipient (${toName}) not found`);
|
||||
sendMsgTo(socket, { type: 'notfound', username: toName });
|
||||
// User is offline — try push notification
|
||||
if (type === 'offerAccept' && config.pushEnabled) {
|
||||
sendPushNotification(toName, socket.username).then((sent) => {
|
||||
if (sent) {
|
||||
sendMsgTo(socket, { type: 'pushSent', username: toName });
|
||||
} else {
|
||||
sendMsgTo(socket, { type: 'notfound', username: toName });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
log.warn(`Recipient (${toName}) not found`);
|
||||
sendMsgTo(socket, { type: 'notfound', username: toName });
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'offerDecline':
|
||||
@@ -624,6 +751,56 @@ function isValidUsername(username) {
|
||||
return usernamePattern.test(username);
|
||||
}
|
||||
|
||||
// Send push notification to an offline user
|
||||
async function sendPushNotification(targetUsername, callerUsername) {
|
||||
const subscriptions = pushSubscriptions.get(targetUsername);
|
||||
if (!subscriptions || subscriptions.length === 0) {
|
||||
log.debug('No push subscription found for', targetUsername);
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
type: 'incomingCall',
|
||||
title: 'Call-me',
|
||||
body: `${callerUsername} is calling you`,
|
||||
caller: callerUsername,
|
||||
url: `/join?user=${encodeURIComponent(targetUsername)}&call=${encodeURIComponent(callerUsername)}`,
|
||||
});
|
||||
|
||||
let anySent = false;
|
||||
const invalidIndices = [];
|
||||
|
||||
for (let i = 0; i < subscriptions.length; i++) {
|
||||
try {
|
||||
await webpush.sendNotification(subscriptions[i], payload);
|
||||
anySent = true;
|
||||
log.debug('Push notification sent to', targetUsername, { device: i + 1 });
|
||||
} catch (err) {
|
||||
log.warn('Push notification failed for', targetUsername, {
|
||||
device: i + 1,
|
||||
statusCode: err.statusCode,
|
||||
message: err.message,
|
||||
});
|
||||
// Remove expired/invalid subscriptions (410 Gone, 404 Not Found)
|
||||
if (err.statusCode === 410 || err.statusCode === 404) {
|
||||
invalidIndices.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up invalid subscriptions
|
||||
if (invalidIndices.length > 0) {
|
||||
const filtered = subscriptions.filter((_, idx) => !invalidIndices.includes(idx));
|
||||
if (filtered.length > 0) {
|
||||
pushSubscriptions.set(targetUsername, filtered);
|
||||
} else {
|
||||
pushSubscriptions.delete(targetUsername);
|
||||
}
|
||||
}
|
||||
|
||||
return anySent;
|
||||
}
|
||||
|
||||
// Function to get all connected users
|
||||
function getConnectedUsers() {
|
||||
return Array.from(users.keys());
|
||||
|
||||
Generated
+123
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "call-me",
|
||||
"version": "1.3.19",
|
||||
"version": "1.3.30",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "call-me",
|
||||
"version": "1.3.19",
|
||||
"version": "1.3.30",
|
||||
"license": "AGPLv3",
|
||||
"dependencies": {
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
@@ -19,7 +19,8 @@
|
||||
"httpolyglot": "0.1.2",
|
||||
"js-yaml": "4.1.1",
|
||||
"socket.io": "^4.8.3",
|
||||
"swagger-ui-express": "5.0.1"
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14",
|
||||
@@ -296,6 +297,15 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
@@ -314,6 +324,18 @@
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@@ -362,6 +384,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.3",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
|
||||
"integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
|
||||
@@ -434,6 +462,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -634,6 +668,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -1103,6 +1146,15 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -1131,6 +1183,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||
@@ -1227,6 +1292,27 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -1276,6 +1362,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.3",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz",
|
||||
@@ -1292,6 +1384,15 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
@@ -1968,6 +2069,25 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
|
||||
+5
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "call-me",
|
||||
"version": "1.3.19",
|
||||
"version": "1.3.30",
|
||||
"description": "Your Go-To for Instant Video Calls",
|
||||
"author": "Miroslav Pejic - miroslav.pejic.85@gmail.com",
|
||||
"license": "AGPLv3",
|
||||
@@ -16,7 +16,8 @@
|
||||
"scripts": {
|
||||
"start": "node app/server.js",
|
||||
"dev": "nodemon app/server.js",
|
||||
"lint": "npx prettier --write ."
|
||||
"lint": "npx prettier --write .",
|
||||
"generate-vapid-keys": "node -e \"const w=require('web-push');const k=w.generateVAPIDKeys();console.log('PUSH_VAPID_PUBLIC_KEY='+k.publicKey);console.log('PUSH_VAPID_PRIVATE_KEY='+k.privateKey)\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@ngrok/ngrok": "1.7.0",
|
||||
@@ -29,7 +30,8 @@
|
||||
"httpolyglot": "0.1.2",
|
||||
"js-yaml": "4.1.1",
|
||||
"socket.io": "^4.8.3",
|
||||
"swagger-ui-express": "5.0.1"
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.14",
|
||||
|
||||
+210
-1
@@ -73,6 +73,9 @@ const incomingCallUsername = document.getElementById('incomingCallUsername');
|
||||
const incomingCallTimer = document.getElementById('incomingCallTimer');
|
||||
const acceptCallBtn = document.getElementById('acceptCallBtn');
|
||||
const declineCallBtn = document.getElementById('declineCallBtn');
|
||||
const pushNotificationGroup = document.getElementById('pushNotificationGroup');
|
||||
const pushNotificationToggle = document.getElementById('pushNotificationToggle');
|
||||
const pushTestBtn = document.getElementById('pushTestBtn');
|
||||
|
||||
// Ensure app is defined, even if config.js is not loaded
|
||||
const app = window.myAppConfig || {};
|
||||
@@ -99,6 +102,10 @@ let allConnectedUsers = [];
|
||||
let filteredUsers = [];
|
||||
let selectedUser = null;
|
||||
|
||||
// Push notification state
|
||||
let pushEnabled = false;
|
||||
let pushSubscription = null;
|
||||
|
||||
// Chat state
|
||||
let unreadMessages = 0;
|
||||
let currentTab = 'users';
|
||||
@@ -455,6 +462,9 @@ function handleMessage(data) {
|
||||
case 'notfound':
|
||||
handleNotFound(data);
|
||||
break;
|
||||
case 'pushSent':
|
||||
handlePushSent(data);
|
||||
break;
|
||||
case 'offerAccept':
|
||||
offerAccept(data);
|
||||
break;
|
||||
@@ -1347,6 +1357,9 @@ function handlePing(data) {
|
||||
if (iceServers) {
|
||||
config.iceServers = iceServers;
|
||||
}
|
||||
if (data.pushEnabled !== undefined) {
|
||||
pushEnabled = data.pushEnabled;
|
||||
}
|
||||
sendMsg({
|
||||
type: 'pong',
|
||||
message: {
|
||||
@@ -1367,6 +1380,20 @@ function handleNotFound(data) {
|
||||
updateParticipantCount();
|
||||
}
|
||||
|
||||
// Handle push notification sent confirmation
|
||||
function handlePushSent(data) {
|
||||
const { username } = data;
|
||||
hideCallingOverlay();
|
||||
toast(
|
||||
t('push.notificationSent', { username }) ||
|
||||
`Notification sent to ${username}. Waiting for them to come online...`,
|
||||
'info',
|
||||
'top',
|
||||
6000
|
||||
);
|
||||
sound('notify');
|
||||
}
|
||||
|
||||
// Handle call declined by remote user
|
||||
function handleOfferDecline(data) {
|
||||
const { from } = data;
|
||||
@@ -1471,6 +1498,11 @@ async function handleSignIn(data) {
|
||||
|
||||
// Send initial media status to server
|
||||
sendMediaStatusToServer();
|
||||
|
||||
// Show push notification toggle if server has push enabled
|
||||
if (pushEnabled) {
|
||||
initPushNotificationToggle();
|
||||
}
|
||||
} else {
|
||||
// All attempts failed, show error only now
|
||||
handleMediaStreamError(lastError);
|
||||
@@ -1646,6 +1678,24 @@ function offerAccept(data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show client-side notification if tab is backgrounded
|
||||
if (document.hidden && Notification.permission === 'granted') {
|
||||
try {
|
||||
const notification = new Notification('Call-me', {
|
||||
body: t('push.incomingCallBody', { caller: data.from }) || `${data.from} is calling you`,
|
||||
icon: '/favicon/favicon-32x32.png',
|
||||
tag: 'call-me-incoming',
|
||||
requireInteraction: true,
|
||||
});
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('Client notification failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
incomingCallData = data;
|
||||
showIncomingCallOverlay(data.from);
|
||||
sound('ring');
|
||||
@@ -2109,6 +2159,165 @@ function handleRemoteScreenShare(data) {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize push notification toggle in settings
|
||||
async function initPushNotificationToggle() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
console.log('Push notifications not supported in this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the toggle group
|
||||
elemDisplay(pushNotificationGroup, true, 'flex');
|
||||
|
||||
// Check current state
|
||||
const registration = await navigator.serviceWorker.getRegistration('/sw.js');
|
||||
if (registration) {
|
||||
const existing = await registration.pushManager.getSubscription();
|
||||
pushNotificationToggle.checked = !!existing;
|
||||
if (existing) {
|
||||
pushSubscription = existing;
|
||||
// Re-send subscription to server (in case server restarted)
|
||||
sendMsg({ type: 'pushSubscription', subscription: existing.toJSON() });
|
||||
}
|
||||
}
|
||||
|
||||
// Restore from localStorage
|
||||
if (localStorage.callMePushEnabled === 'true' && !pushNotificationToggle.checked) {
|
||||
pushNotificationToggle.checked = true;
|
||||
await registerPushNotifications();
|
||||
}
|
||||
|
||||
// Show/hide test button based on current state
|
||||
updatePushTestBtn();
|
||||
|
||||
// Handle toggle change
|
||||
pushNotificationToggle.addEventListener('change', handlePushToggle);
|
||||
|
||||
// Handle test button click
|
||||
pushTestBtn.addEventListener('click', handlePushTest);
|
||||
}
|
||||
|
||||
// Handle push notification toggle
|
||||
async function handlePushToggle() {
|
||||
if (pushNotificationToggle.checked) {
|
||||
await registerPushNotifications();
|
||||
if (!pushSubscription) {
|
||||
// Permission denied or failed — revert toggle
|
||||
pushNotificationToggle.checked = false;
|
||||
localStorage.callMePushEnabled = 'false';
|
||||
toast(t('push.permissionDenied') || 'Notification permission denied', 'warning', 'top', 3000);
|
||||
updatePushTestBtn();
|
||||
return;
|
||||
}
|
||||
localStorage.callMePushEnabled = 'true';
|
||||
toast(t('push.enabled') || 'Push notifications enabled', 'success', 'top', 3000);
|
||||
} else {
|
||||
await unregisterPushNotifications();
|
||||
localStorage.callMePushEnabled = 'false';
|
||||
toast(t('push.disabled') || 'Push notifications disabled', 'info', 'top', 3000);
|
||||
}
|
||||
updatePushTestBtn();
|
||||
}
|
||||
|
||||
// Show/hide test push button based on toggle state
|
||||
function updatePushTestBtn() {
|
||||
elemDisplay(pushTestBtn, pushNotificationToggle.checked);
|
||||
}
|
||||
|
||||
// Handle test push notification button
|
||||
function handlePushTest() {
|
||||
sendMsg({ type: 'testPush' });
|
||||
toast(t('push.testSent') || 'Test notification sent', 'info', 'top', 3000);
|
||||
}
|
||||
|
||||
// Register service worker and subscribe to push notifications
|
||||
async function registerPushNotifications() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
console.log('Push notifications not supported in this browser');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch VAPID public key from server
|
||||
const { data: vapidData } = await axios.get('/api/v1/vapidPublicKey');
|
||||
if (!vapidData.enabled || !vapidData.vapidPublicKey) {
|
||||
console.log('Push notifications not enabled on server');
|
||||
return;
|
||||
}
|
||||
|
||||
// Register service worker and wait for it to become active
|
||||
await navigator.serviceWorker.register('/sw.js');
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
console.log('Service worker registered and active');
|
||||
|
||||
// Request notification permission
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
console.log('Notification permission denied');
|
||||
pushSubscription = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for existing subscription first
|
||||
pushSubscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (!pushSubscription) {
|
||||
// Subscribe to push
|
||||
const applicationServerKey = urlBase64ToUint8Array(vapidData.vapidPublicKey);
|
||||
pushSubscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey,
|
||||
});
|
||||
console.log('Push notification: new subscription created');
|
||||
} else {
|
||||
console.log('Push notification: using existing subscription');
|
||||
}
|
||||
|
||||
// Send subscription to server via Socket.IO
|
||||
sendMsg({
|
||||
type: 'pushSubscription',
|
||||
subscription: pushSubscription.toJSON(),
|
||||
});
|
||||
|
||||
console.log('Push notification subscription successful', pushSubscription.endpoint);
|
||||
} catch (err) {
|
||||
console.warn('Push notification registration failed:', err);
|
||||
pushSubscription = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe from push notifications
|
||||
async function unregisterPushNotifications() {
|
||||
try {
|
||||
if (pushSubscription) {
|
||||
const endpoint = pushSubscription.endpoint;
|
||||
await pushSubscription.unsubscribe();
|
||||
console.log('Push subscription removed from browser');
|
||||
|
||||
// Tell server to remove the subscription
|
||||
sendMsg({
|
||||
type: 'pushUnsubscribe',
|
||||
endpoint: endpoint,
|
||||
});
|
||||
}
|
||||
pushSubscription = null;
|
||||
} catch (err) {
|
||||
console.warn('Push unsubscribe failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert URL-safe base64 string to Uint8Array (for VAPID key)
|
||||
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;
|
||||
}
|
||||
|
||||
// Send messages to the server
|
||||
function sendMsg(message) {
|
||||
// Use connectedUser if call is established, otherwise use pendingUser for signaling
|
||||
@@ -2465,7 +2674,7 @@ function renderUserList() {
|
||||
});
|
||||
|
||||
// Initialize Bootstrap tooltips on dynamically created buttons (skip on mobile)
|
||||
if (!userInfo.device.isMobile) {
|
||||
if (userInfo && !userInfo.device.isMobile) {
|
||||
const tooltipEls = userList.querySelectorAll('[title]');
|
||||
tooltipEls.forEach((el) => {
|
||||
el.setAttribute('data-toggle', 'tooltip');
|
||||
|
||||
@@ -392,6 +392,20 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="pushNotificationGroup" class="setting-group setting-toggle-group">
|
||||
<label for="pushNotificationToggle" class="setting-label">
|
||||
<i class="fas fa-bell"></i>
|
||||
<span data-i18n="settings.pushNotifications">Push Notifications</span>
|
||||
</label>
|
||||
<div class="push-toggle-wrapper">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="pushNotificationToggle" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
<button id="pushTestBtn" class="btn-setting" data-i18n="settings.testPush">Test</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="settings-title">
|
||||
<i class="fas fa-video"></i> <span data-i18n="settings.mediaDevicesTitle">Media Devices</span>
|
||||
</h3>
|
||||
|
||||
@@ -1918,6 +1918,38 @@ input {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#pushNotificationGroup {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.push-toggle-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#pushTestBtn {
|
||||
display: none;
|
||||
padding: 4px 14px;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--glass-bg);
|
||||
color: var(--text-color);
|
||||
font-size: 0.8rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#pushTestBtn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-light);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 10px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use strict';
|
||||
|
||||
// Service Worker for Web Push Notifications
|
||||
|
||||
// Activate immediately — don't wait for old clients to close
|
||||
self.addEventListener('install', (event) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('Push event received');
|
||||
|
||||
let title = 'Call-me';
|
||||
let body = 'You have a new notification';
|
||||
let url = '/';
|
||||
let type = 'notification';
|
||||
let caller = '';
|
||||
|
||||
if (event.data) {
|
||||
try {
|
||||
const payload = event.data.json();
|
||||
title = payload.title || title;
|
||||
body = payload.body || body;
|
||||
url = payload.url || url;
|
||||
type = payload.type || type;
|
||||
caller = payload.caller || caller;
|
||||
} catch (e) {
|
||||
body = event.data.text() || body;
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
body: body,
|
||||
icon: '/favicon/favicon-32x32.png',
|
||||
badge: '/favicon/favicon-16x16.png',
|
||||
// tag: 'call-me-' + type,
|
||||
// renotify: true,
|
||||
data: { url, type, caller },
|
||||
};
|
||||
|
||||
console.log('Showing notification:', title, options);
|
||||
|
||||
const promiseChain = self.registration
|
||||
.showNotification(title, options)
|
||||
.then(() => console.log('Notification shown successfully'))
|
||||
.catch((err) => console.error('showNotification failed:', err));
|
||||
|
||||
event.waitUntil(promiseChain);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const url = event.notification.data?.url || '/';
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
|
||||
// Try to focus an existing tab with the app
|
||||
for (const client of windowClients) {
|
||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||
client.focus();
|
||||
// Navigate to the call URL if different
|
||||
if (url !== '/' && !client.url.includes(url)) {
|
||||
client.navigate(self.location.origin + url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// No existing tab found, open a new one
|
||||
return clients.openWindow(self.location.origin + url);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('pushsubscriptionchange', (event) => {
|
||||
event.waitUntil(
|
||||
self.registration.pushManager
|
||||
.subscribe(event.oldSubscription.options)
|
||||
.then((subscription) => {
|
||||
// Notify the server about the new subscription
|
||||
return fetch('/api/v1/pushSubscription', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subscription }),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to resubscribe on pushsubscriptionchange:', err);
|
||||
})
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user