334 lines
8.8 KiB
JavaScript
334 lines
8.8 KiB
JavaScript
'use strict';
|
|
|
|
// ========== Storage abstraction (decisions #1024, #1025) ==========
|
|
//
|
|
// #1024: probe availability with a real write, NOT typeof/in
|
|
// #1025: fallback chain: localStorage → sessionStorage → in-memory
|
|
|
|
let _storage = null;
|
|
let _storageType = 'memory';
|
|
const _mem = {};
|
|
|
|
function _probeStorage(s) {
|
|
try {
|
|
const k = '__baton_probe__';
|
|
s.setItem(k, '1');
|
|
s.removeItem(k);
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function _initStorage() {
|
|
if (_probeStorage(localStorage)) {
|
|
_storage = localStorage;
|
|
_storageType = 'local';
|
|
} else if (_probeStorage(sessionStorage)) {
|
|
_storage = sessionStorage;
|
|
_storageType = 'session';
|
|
} else {
|
|
_storage = {
|
|
getItem: (k) => (Object.prototype.hasOwnProperty.call(_mem, k) ? _mem[k] : null),
|
|
setItem: (k, v) => { _mem[k] = String(v); },
|
|
removeItem: (k) => { delete _mem[k]; },
|
|
};
|
|
_storageType = 'memory';
|
|
}
|
|
}
|
|
|
|
// ========== User identity ==========
|
|
|
|
function _getOrCreateUserId() {
|
|
let id = _storage.getItem('baton_user_id');
|
|
if (!id) {
|
|
id = crypto.randomUUID();
|
|
_storage.setItem('baton_user_id', id);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
function _isRegistered() {
|
|
return _storage.getItem('baton_registered') === '1';
|
|
}
|
|
|
|
function _getUserName() {
|
|
return _storage.getItem('baton_user_name') || '';
|
|
}
|
|
|
|
function _getApiKey() {
|
|
return _storage.getItem('baton_api_key') || '';
|
|
}
|
|
|
|
function _saveRegistration(name, apiKey) {
|
|
_storage.setItem('baton_user_name', name);
|
|
_storage.setItem('baton_registered', '1');
|
|
if (apiKey) _storage.setItem('baton_api_key', apiKey);
|
|
}
|
|
|
|
function _getInitials(name) {
|
|
const trimmed = (name || '').trim();
|
|
if (!trimmed) return '?';
|
|
return trimmed
|
|
.split(/\s+/)
|
|
.map((w) => w[0] || '')
|
|
.join('')
|
|
.toUpperCase()
|
|
.slice(0, 2);
|
|
}
|
|
|
|
// ========== UI helpers ==========
|
|
|
|
function _showScreen(id) {
|
|
document.querySelectorAll('.screen').forEach((el) => {
|
|
el.hidden = el.id !== id;
|
|
});
|
|
}
|
|
|
|
function _setStatus(msg, cls) {
|
|
const el = document.getElementById('status');
|
|
el.textContent = msg;
|
|
el.className = 'status' + (cls ? ' status--' + cls : '');
|
|
el.hidden = !msg;
|
|
}
|
|
|
|
function _updateNetworkIndicator() {
|
|
const el = document.getElementById('indicator-network');
|
|
if (!el) return;
|
|
el.className = 'network-indicator ' + (navigator.onLine ? 'online' : 'offline');
|
|
el.setAttribute('aria-label', navigator.onLine ? 'Online' : 'Offline');
|
|
}
|
|
|
|
function _updateUserAvatar() {
|
|
const el = document.getElementById('user-avatar');
|
|
if (!el) return;
|
|
el.textContent = _getInitials(_getUserName());
|
|
}
|
|
|
|
// ========== API calls ==========
|
|
|
|
async function _apiPost(path, body, extraHeaders) {
|
|
const res = await fetch(path, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', ...extraHeaders },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
const err = new Error('HTTP ' + res.status + (text ? ': ' + text : ''));
|
|
err.status = res.status;
|
|
throw err;
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
// ========== Geolocation (optional, non-blocking) ==========
|
|
|
|
function _getGeo() {
|
|
return new Promise((resolve) => {
|
|
if (!navigator.geolocation) { resolve(null); return; }
|
|
navigator.geolocation.getCurrentPosition(
|
|
({ coords }) =>
|
|
resolve({
|
|
lat: coords.latitude,
|
|
lon: coords.longitude,
|
|
accuracy: coords.accuracy,
|
|
}),
|
|
() => resolve(null),
|
|
{ timeout: 5000, maximumAge: 30000 }
|
|
);
|
|
});
|
|
}
|
|
|
|
// ========== Handlers ==========
|
|
|
|
async function _handleRegister() {
|
|
const input = document.getElementById('name-input');
|
|
const btn = document.getElementById('btn-confirm');
|
|
const name = input.value.trim();
|
|
if (!name) return;
|
|
|
|
btn.disabled = true;
|
|
_setStatus('', '');
|
|
|
|
try {
|
|
const uuid = _getOrCreateUserId();
|
|
const data = await _apiPost('/api/register', { uuid, name });
|
|
_saveRegistration(name, data.api_key);
|
|
_updateUserAvatar();
|
|
_showMain();
|
|
} catch (_) {
|
|
_setStatus('Error. Please try again.', 'error');
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function _setSosState(state) {
|
|
const btn = document.getElementById('btn-sos');
|
|
if (!btn) return;
|
|
btn.dataset.state = state;
|
|
btn.disabled = state === 'sending';
|
|
}
|
|
|
|
async function _handleSignal() {
|
|
// v1: no offline queue — show error and return (decision #1019)
|
|
if (!navigator.onLine) {
|
|
_setStatus('No connection. Check your network and try again.', 'error');
|
|
return;
|
|
}
|
|
|
|
_setSosState('sending');
|
|
_setStatus('', '');
|
|
|
|
try {
|
|
const geo = await _getGeo();
|
|
const uuid = _getOrCreateUserId();
|
|
const body = { user_id: uuid, timestamp: Date.now() };
|
|
if (geo) body.geo = geo;
|
|
|
|
const apiKey = _getApiKey();
|
|
const authHeaders = apiKey ? { Authorization: 'Bearer ' + apiKey } : {};
|
|
await _apiPost('/api/signal', body, authHeaders);
|
|
|
|
_setSosState('success');
|
|
_setStatus('Signal sent!', 'success');
|
|
setTimeout(() => {
|
|
_setSosState('default');
|
|
_setStatus('', '');
|
|
}, 2000);
|
|
} catch (err) {
|
|
_setSosState('default');
|
|
if (err && err.status === 401) {
|
|
_setStatus('Session expired or key is invalid. Please re-register.', 'error');
|
|
} else {
|
|
_setStatus('Error sending. Try again.', 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
// ========== Screens ==========
|
|
|
|
function _showOnboarding() {
|
|
_showScreen('screen-onboarding');
|
|
|
|
const input = document.getElementById('name-input');
|
|
const btn = document.getElementById('btn-confirm');
|
|
|
|
input.addEventListener('input', () => {
|
|
btn.disabled = input.value.trim().length === 0;
|
|
});
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !btn.disabled) _handleRegister();
|
|
});
|
|
btn.addEventListener('click', _handleRegister);
|
|
}
|
|
|
|
function _showMain() {
|
|
_showScreen('screen-main');
|
|
_updateUserAvatar();
|
|
|
|
const btn = document.getElementById('btn-sos');
|
|
if (btn && !btn.dataset.listenerAttached) {
|
|
btn.addEventListener('click', _handleSignal);
|
|
btn.dataset.listenerAttached = '1';
|
|
}
|
|
}
|
|
|
|
// ========== Service Worker ==========
|
|
|
|
function _registerSW() {
|
|
if (!('serviceWorker' in navigator)) return;
|
|
navigator.serviceWorker.register('/sw.js').catch((err) => {
|
|
console.warn('[baton] SW registration failed:', err);
|
|
});
|
|
}
|
|
|
|
// ========== VAPID / Push subscription ==========
|
|
|
|
async function _fetchVapidPublicKey() {
|
|
try {
|
|
const res = await fetch('/api/vapid-public-key');
|
|
if (!res.ok) {
|
|
console.warn('[baton] /api/vapid-public-key returned', res.status);
|
|
return null;
|
|
}
|
|
const data = await res.json();
|
|
return data.vapid_public_key || null;
|
|
} catch (err) {
|
|
console.warn('[baton] Failed to fetch VAPID public key:', err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function _urlBase64ToUint8Array(base64String) {
|
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
|
const raw = atob(base64);
|
|
const output = new Uint8Array(raw.length);
|
|
for (let i = 0; i < raw.length; i++) {
|
|
output[i] = raw.charCodeAt(i);
|
|
}
|
|
return output;
|
|
}
|
|
|
|
async function _initPushSubscription(vapidPublicKey) {
|
|
if (!vapidPublicKey) {
|
|
console.warn('[baton] VAPID public key not available — push subscription skipped');
|
|
return;
|
|
}
|
|
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
|
return;
|
|
}
|
|
try {
|
|
const registration = await navigator.serviceWorker.ready;
|
|
const existing = await registration.pushManager.getSubscription();
|
|
if (existing) return;
|
|
const applicationServerKey = _urlBase64ToUint8Array(vapidPublicKey);
|
|
const subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey,
|
|
});
|
|
_storage.setItem('baton_push_subscription', JSON.stringify(subscription));
|
|
console.info('[baton] Push subscription created');
|
|
} catch (err) {
|
|
console.warn('[baton] Push subscription failed:', err);
|
|
}
|
|
}
|
|
|
|
// ========== Init ==========
|
|
|
|
function _init() {
|
|
_initStorage();
|
|
|
|
// Pre-generate and persist UUID on first visit (per arch spec flow)
|
|
_getOrCreateUserId();
|
|
|
|
// Private mode graceful degradation (decision #1041):
|
|
// show inline banner with explicit action guidance when localStorage is unavailable
|
|
if (_storageType !== 'local') {
|
|
const banner = document.getElementById('private-mode-banner');
|
|
if (banner) banner.hidden = false;
|
|
}
|
|
|
|
// Network indicator — initial state + live updates
|
|
_updateNetworkIndicator();
|
|
window.addEventListener('online', _updateNetworkIndicator);
|
|
window.addEventListener('offline', _updateNetworkIndicator);
|
|
|
|
// Route to correct screen
|
|
if (_isRegistered()) {
|
|
_showMain();
|
|
} else {
|
|
_showOnboarding();
|
|
}
|
|
|
|
// Fire-and-forget: fetch VAPID key from API and subscribe to push (non-blocking)
|
|
_fetchVapidPublicKey().then(_initPushSubscription).catch((err) => {
|
|
console.warn('[baton] Push init error:', err);
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
_registerSW();
|
|
_init();
|
|
});
|