// Version: 1.0.0 | Created: 2026-04-27 // Web Push subscription and Matrix pusher registration. // Only active on web — no-ops on other platforms. // // Flow: // 1. registerPushServiceWorker() — register push_sw.js (called at startup) // 2. setupWebPush(client) — called after login; requests permission, // subscribes to Web Push, registers the subscription as a Matrix pusher. import 'dart:async'; import 'dart:convert'; import 'dart:js_interop'; import 'dart:js_interop_unsafe'; import 'package:flutter/foundation.dart'; import 'package:matrix/matrix.dart'; import 'package:web/web.dart' as web; const _kVapidPublicKey = 'BPP0AqC7qTxspQn1i0x2sPbPj3owJTTkhV256A67VUK9748NjaBfbMrQBJtI58foVSVpbk2n390NIq_w2glNjs8'; const _kAppId = 'au.m8chat.web.push'; const _kPushGatewayUrl = 'https://matrix.m8chat.au/_matrix/push/v1/notify'; /// Registers push_sw.js at startup so the browser knows the service worker /// before the user logs in. Idempotent. No-op on non-web platforms. Future registerPushServiceWorker() async { if (!kIsWeb) return; try { await _registerServiceWorker(); } catch (e) { debugPrint('[Push] SW pre-registration failed (non-fatal): $e'); } } /// Called after login. Requests notification permission, subscribes to Web /// Push using the VAPID public key, and registers the subscription as a /// Matrix pusher so Synapse routes notifications through Sygnal → browser. /// /// Idempotent — safe to call on every login. No-op on non-web platforms. Future setupWebPush(Client client) async { if (!kIsWeb) return; try { final sw = await _registerServiceWorker(); if (sw == null) { debugPrint('[Push] SW not available — push skipped'); return; } final permission = await _requestPermission(); if (permission != 'granted') { debugPrint('[Push] Notification permission $permission — skipped'); return; } final sub = await _subscribe(sw); if (sub == null) { debugPrint('[Push] Could not create push subscription'); return; } await _registerPusher(client, sub); } catch (e) { debugPrint('[Push] Setup failed (non-fatal): $e'); } } // --------------------------------------------------------------------------- Future _registerServiceWorker() async { if (!_pushSupported()) return null; try { final reg = await web.window.navigator.serviceWorker .register('/push_sw.js'.toJS, web.RegistrationOptions(scope: '/')) .toDart; debugPrint('[Push] Service worker registered: ${reg.scope}'); return reg; } catch (e) { debugPrint('[Push] Service worker error: $e'); return null; } } Future _requestPermission() async { try { final result = await web.Notification.requestPermission().toDart; return result.toDart; } catch (_) { return 'denied'; } } Future _subscribe( web.ServiceWorkerRegistration reg) async { try { // Re-use existing subscription if the browser already has one. final existing = await reg.pushManager.getSubscription().toDart; if (existing != null) { debugPrint('[Push] Reusing existing push subscription'); return existing; } final keyBytes = _vapidKeyToBytes(_kVapidPublicKey); final sub = await reg.pushManager .subscribe(web.PushSubscriptionOptionsInit( userVisibleOnly: true, applicationServerKey: keyBytes, )) .toDart; debugPrint('[Push] New subscription: ${sub.endpoint}'); return sub; } catch (e) { debugPrint('[Push] Subscribe error: $e'); return null; } } Future _registerPusher( Client client, web.PushSubscription sub) async { final json = sub.toJSON(); final endpoint = json.endpoint; if (endpoint.isEmpty) return; // Extract p256dh and auth keys from the subscription. final keysObj = json.keys; final p256dh = _jsObjectGet(keysObj, 'p256dh') ?? ''; final auth = _jsObjectGet(keysObj, 'auth') ?? ''; await client.postPusher( Pusher( pushkey: endpoint, kind: 'http', appId: _kAppId, appDisplayName: 'M8Chat Web', deviceDisplayName: 'Web browser', lang: 'en', data: PusherData( url: Uri.parse(_kPushGatewayUrl), format: 'event_id_only', additionalProperties: { 'keys': {'p256dh': p256dh, 'auth': auth}, }, ), ), ); debugPrint('[Push] Matrix pusher registered'); } /// Converts a URL-safe base64 VAPID public key to a JS Uint8Array. JSUint8Array _vapidKeyToBytes(String base64Url) { final padded = base64Url + '=' * ((4 - base64Url.length % 4) % 4); final standard = padded.replaceAll('-', '+').replaceAll('_', '/'); final bytes = base64Decode(standard); return bytes.toJS; } /// Reads a string property from a JSObject via js_interop. String? _jsObjectGet(JSObject obj, String key) { try { final val = obj.getProperty(key.toJS); if (val == null || val.isUndefinedOrNull) return null; return (val as JSString).toDart; } catch (_) { return null; } } bool _pushSupported() { try { web.window.navigator.serviceWorker; return true; } catch (_) { return false; } }