north-star / commit
modularized the app
src/app.js | 1284 ++++----------------------------------------- src/compass.js | 312 +++++++++++ src/crypto.js | 35 ++ src/darkmode.js | 24 + src/firebase.js | 27 + src/participants-panel.js | 339 ++++++++++++ src/participants-sync.js | 59 +++ src/qr.js | 51 ++ src/renderer.js | 78 +++ src/room.js | 18 + src/self-profile.js | 109 ++++ src/session.js | 69 +++ src/utils.js | 71 +++ src/wakelock.js | 24 + 14 files changed, 1310 insertions(+), 1190 deletions(-) diff --git a/src/app.js b/src/app.js index 7ddb00a..f37505a 100644 --- a/src/app.js +++ b/src/app.js @@ -1,11 +1,15 @@ -import { onValue } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js"; -import { initializeApp } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-app.js"; -import { - getAuth, - signInAnonymously, -} from 'https://www.gstatic.com/firebasejs/12.10.0/firebase-auth.js'; -import { getDatabase, ref, set, onDisconnect } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js"; -import QRCode from 'https://cdn.jsdelivr.net/npm/qrcode@1.5.4/+esm'; +import { onDisconnect, ref, set } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js"; +import { getReadableTextColor, getArrowDisplayColor } from './utils.js'; +import { encryptJson } from './crypto.js'; +import { initDarkMode } from './darkmode.js'; +import { createWakeLockManager } from './wakelock.js'; +import { initFirebaseSession } from './firebase.js'; +import { initSession } from './session.js'; +import { initSelfProfile } from './self-profile.js'; +import { subscribeToParticipants } from './participants-sync.js'; +import { initCompassController } from './compass.js'; +import { createParticipantsPanelController } from './participants-panel.js'; +import { renderArrows } from './renderer.js'; const debugParagraph = document.getElementById('debug'); const nameForm = document.getElementById('name-form'); @@ -18,65 +22,33 @@ const darkModeToggle = document.getElementById('dark-mode-toggle'); const createRoomLink = document.getElementById('create-room-link'); const participantsPanel = document.getElementById('participants-panel'); const participantsToggle = document.getElementById('participants-toggle'); +const content = document.getElementById('content'); +const arrow = document.getElementById('default-arrow'); + const logDebug = (message) => { if (message !== debugParagraph.innerHTML) { debugParagraph.innerHTML = message; } -} +}; -const arrow = document.getElementById('default-arrow'); document.head.appendChild(Object.assign(document.createElement('link'), { rel: 'icon', href: './image.png' })); -const getShortestDelta = (fromAngle, toAngle) => ((toAngle - fromAngle + 540) % 360) - 180; -const normalizeAngle = (angle) => ((angle % 360) + 360) % 360; -const normalizeSignedAngle = (angle) => { - const normalized = normalizeAngle(angle); - return normalized > 180 ? normalized - 360 : normalized; -}; + const RENDER_INTERVAL_MS = 100; const LOCATION_SYNC_INTERVAL_MS = 3000; const HEADING_CORRECTION_STORAGE_KEY = 'north-star-heading-correction-deg'; -const DARK_MODE_STORAGE_KEY = 'north-star-dark-mode'; +const NORTH_MARKER_ID = 'north-marker'; +const GUARDIAN_ANGEL_ID = 'test'; + let headingCorrectionDeg = (() => { const parsed = Number(localStorage.getItem(HEADING_CORRECTION_STORAGE_KEY)); return Number.isFinite(parsed) ? parsed : 0; })(); + let isLocalCommit = true; let GIT_COMMIT = 'local-dev'; -const applyDarkMode = (dark) => { - document.body.classList.toggle('dark', dark); - if (darkModeToggle) darkModeToggle.textContent = dark ? '☀️' : '🌙'; - localStorage.setItem(DARK_MODE_STORAGE_KEY, dark ? '1' : '0'); -}; -const storedDarkMode = localStorage.getItem(DARK_MODE_STORAGE_KEY); -const prefersDark = storedDarkMode !== null - ? storedDarkMode === '1' - : window.matchMedia('(prefers-color-scheme: dark)').matches; -applyDarkMode(prefersDark); -if (darkModeToggle) { - darkModeToggle.addEventListener('click', () => applyDarkMode(!document.body.classList.contains('dark'))); -} -let wakeLockSentinel = null; -const hasWakeLockSupport = typeof navigator !== 'undefined' && 'wakeLock' in navigator; - -const requestWakeLock = async () => { - if (!hasWakeLockSupport) return; - if (document.visibilityState !== 'visible') return; - if (wakeLockSentinel) return; - - try { - wakeLockSentinel = await navigator.wakeLock.request('screen'); - wakeLockSentinel.addEventListener('release', () => { - wakeLockSentinel = null; - if (document.visibilityState === 'visible') { - void requestWakeLock(); - } - }); - } catch (err) { - // Wake lock can fail on unsupported browsers or restricted contexts. - console.debug('Wake lock request failed', err); - } -}; +initDarkMode(darkModeToggle); +const { requestWakeLock } = createWakeLockManager(); try { const commitModule = await import('./commit.js'); @@ -88,871 +60,89 @@ try { // Local/dev environments may not have commit.js. } -/** - * @typedef {Object} Position - * @property {number} lat - * @property {number} lon - */ - -/** - * @typedef {Object} Participant - * @property {string} id - * @property {string} name - * @property {string} color - * @property {Position} position - * @property {number} timestamp - * - * { - * id: 'abc123', - * name: 'Alice', - * color: '#ff0000', - * position: { - * lat: 37.7749, - * lon: -122.4194 - * }, - * timestamp: 1625247600000 - * } - */ - -// Space Needle: 47.62055039884746, -122.34928833200904 -const spaceNeedle = { lat: 47.62055039884746, lon: -122.34928833200904 }; -// Cascadia Pizza: 47.62664023883763, -122.1661814443633 -const cascadiaPizza = { lat: 47.62664023883763, lon: -122.1661814443633 }; - const selfPosition = { lat: 47.62055039884746, lon: -122.34928833200904 }; -// Heading, assume north orientation by default, but if we get a heading from the device, use that to rotate the arrow accordingly -let selfHeading = 0; -let lastRawHeading = null; - -// ── End-to-end participant-state encryption ──────────────────────────────── -// The room key lives only in the URL hash (#key=…), which browsers never send -// to any server. Firebase therefore stores only opaque ciphertext. -const _AES_PARAMS = { name: 'AES-GCM', length: 256 }; -const _IV_BYTES = 12; - -const generateRoomKey = () => - crypto.subtle.generateKey(_AES_PARAMS, true, ['encrypt', 'decrypt']); - -const exportRoomKey = async (key) => { - const raw = await crypto.subtle.exportKey('raw', key); - return btoa(String.fromCharCode(...new Uint8Array(raw))) - .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); -}; -const importRoomKey = async (base64url) => { - const b64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); - const padded = b64 + '='.repeat((4 - b64.length % 4) % 4); - const raw = Uint8Array.from(atob(padded), c => c.charCodeAt(0)); - return crypto.subtle.importKey('raw', raw, _AES_PARAMS, false, ['encrypt', 'decrypt']); -}; - -const encryptJson = async (key, value) => { - const iv = crypto.getRandomValues(new Uint8Array(_IV_BYTES)); - const plaintext = new TextEncoder().encode(JSON.stringify(value)); - const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext); - const ivB64 = btoa(String.fromCharCode(...iv)); - const ctB64 = btoa(String.fromCharCode(...new Uint8Array(ciphertext))); - return `${ivB64}:${ctB64}`; -}; - -const decryptJson = async (key, encryptedValue) => { - const [ivB64, ctB64] = encryptedValue.split(':'); - const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0)); - const ciphertext = Uint8Array.from(atob(ctB64), c => c.charCodeAt(0)); - const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); - return JSON.parse(new TextDecoder().decode(plaintext)); -}; -// ───────────────────────────────────────────────────────────────────────────── - -const firebaseConfig = { - apiKey: "AIzaSyA4G7e13Gt1XAIds1Y63X_RFw-2O48iCLM", - authDomain: "north-star-e5dee.firebaseapp.com", - projectId: "north-star-e5dee", - storageBucket: "north-star-e5dee.firebasestorage.app", - messagingSenderId: "999461150314", - appId: "1:999461150314:web:53f3c6216dd4b0468bf413", - databaseURL: "https://north-star-e5dee-default-rtdb.firebaseio.com/" -}; -initializeApp(firebaseConfig); - -const app = initializeApp(firebaseConfig); -const auth = getAuth(app); -const db = getDatabase(app); - -const authSignInResponse = await signInAnonymously(auth); -console.debug('Signed in anonymously:', authSignInResponse); -const uid = authSignInResponse.user.uid; -console.debug('User ID:', uid); +const { db, uid } = await initFirebaseSession(); const BASE_ROOM_URL = `${window.location.origin}${window.location.pathname}`; const GITLAB_PAGES_BASE_URL = 'https://pavelsafronov.gitlab.io/north-star/'; -const ROOM_ID_LENGTH = 8; -const ROOM_KEY_PATTERN = /^[A-Za-z0-9_-]+$/; - -const buildRoomHash = (nextRoomId, nextRoomKeyBase64 = null) => { - const keyPart = nextRoomKeyBase64 ? `.${nextRoomKeyBase64}` : ''; - return `${nextRoomId}${keyPart}`; -}; - -// Parse room ID and optional encryption key from compact hash format: #<roomId>[.<roomKeyBase64>] -const parseRoomFromHash = (hashValue) => { - const normalizedHash = (hashValue || '').startsWith('#') ? hashValue.slice(1) : (hashValue || ''); - - const compactMatch = normalizedHash.match(/^([a-z0-9]{8})(?:\.([A-Za-z0-9_-]+))?$/); - if (!compactMatch) return null; - - const roomValue = compactMatch[1]; - const keyValue = compactMatch[2] || null; - if (keyValue && !ROOM_KEY_PATTERN.test(keyValue)) return null; - return { roomId: roomValue, roomKeyBase64: keyValue }; -}; - -let roomKey = null; // CryptoKey | null — null means plaintext fallback -let roomKeyBase64 = null; // string | null -let roomId; - -const parsedRoom = parseRoomFromHash(location.hash); -if (parsedRoom) { - roomId = parsedRoom.roomId; - if (parsedRoom.roomKeyBase64) { - try { - roomKey = await importRoomKey(parsedRoom.roomKeyBase64); - roomKeyBase64 = parsedRoom.roomKeyBase64; - } catch (err) { - console.error('Failed to import room key from URL — running without encryption', err); - } - } -} else { - // New room: generate a fresh AES-GCM key and embed it in the hash. - roomId = crypto.randomUUID().replace(/-/g, '').slice(0, ROOM_ID_LENGTH); - roomKey = await generateRoomKey(); - roomKeyBase64 = await exportRoomKey(roomKey); - location.hash = buildRoomHash(roomId, roomKeyBase64); -} -console.debug('Room ID:', roomId, '| Encrypted:', roomKey !== null); - -if (roomCurrent) { - roomCurrent.textContent = `You are currently in room ${roomId}`; -} - -const inviteRoomUrl = `${BASE_ROOM_URL}#${buildRoomHash(roomId, roomKeyBase64)}`; -if (createRoomLink) { - createRoomLink.href = BASE_ROOM_URL; -} - -const renderInviteQrCard = async ({ title, url, statusText }) => { - if (!inviteRoomQrList) return; - - const card = document.createElement('div'); - card.className = 'room-qr-card'; - card.setAttribute('aria-label', title ? `${title} invite QR code` : 'Invite QR code'); - - if (title) { - const label = document.createElement('p'); - label.className = 'room-qr-title'; - label.textContent = title; - card.appendChild(label); - } - - const qrImage = document.createElement('img'); - qrImage.className = 'room-qr'; - qrImage.alt = title ? `${title}: scan to join room ${roomId}` : `Scan to join room ${roomId}`; - card.appendChild(qrImage); - - const status = document.createElement('p'); - status.className = 'room-link-status'; - status.textContent = statusText; - card.appendChild(status); - - inviteRoomQrList.appendChild(card); - - try { - const qrDataUrl = await QRCode.toDataURL(url, { - width: 400, - margin: 4, - errorCorrectionLevel: 'H', - color: { - dark: '#0f172a', - light: '#ffffff' - } - }); - qrImage.src = qrDataUrl; - } catch (err) { - console.error('Failed to generate invite QR code', err); - status.textContent = 'Failed to create QR'; - status.classList.add('error'); - } -}; - -if (inviteRoomQrList) { - inviteRoomQrList.textContent = ''; - const isLocalRun = isLocalCommit || ['localhost', '127.0.0.1', '::1'].includes(location.hostname); - - const roomHash = buildRoomHash(roomId, roomKeyBase64); - await renderInviteQrCard({ - title: isLocalRun ? 'This Device URL' : '', - url: inviteRoomUrl, - statusText: 'Scan to join this room' - }); - - if (isLocalRun) { - await renderInviteQrCard({ - title: 'GitLab Pages URL', - url: `${GITLAB_PAGES_BASE_URL}#${roomHash}`, - statusText: 'Scan to join on hosted app' - }); - } -} - -const requiresExplicitCompassPermission = - typeof DeviceOrientationEvent !== 'undefined' && - typeof DeviceOrientationEvent.requestPermission === 'function'; -const hasDeviceOrientationSupport = typeof DeviceOrientationEvent !== 'undefined'; -const compassDefinitelyUnsupported = !hasDeviceOrientationSupport; -let compassEnabled = !requiresExplicitCompassPermission; -let hasSeenOrientationEvent = false; -let hasSeenHeadingValue = false; -let compassButtonStatusLabel = ''; -let compassButtonStatusTitle = ''; -let compassButtonStatusError = false; -let compassStatusTimer = null; -let compassProbeTimer = null; -const COMPASS_PROBE_TIMEOUT_MS = 2500; - -const getHeadingUnavailableMessage = () => { - if (compassDefinitelyUnsupported) { - return 'This browser/device does not expose orientation sensors, so a compass is unavailable here.'; - } - - if (requiresExplicitCompassPermission && !compassEnabled) { - return 'Compass access is not enabled yet. Tap Enable Compass first.'; - } - - if (!hasSeenOrientationEvent) { - return 'No orientation sensor data has been detected on this device. This setup likely has no compass/motion sensor available (common on desktop/laptop hardware).'; - } - - if (!hasSeenHeadingValue) { - return 'Orientation events are arriving, but compass heading is unavailable in this browser/device context.'; - } - - return 'Heading sensor data is not ready yet. Try again in a moment.'; -}; - -const getHeadingUnavailableStatus = () => { - if (compassDefinitelyUnsupported) { - return { - label: 'Compass Unavailable', - title: 'This browser/device does not expose orientation sensors, so a compass is unavailable here.', - isError: true - }; - } - - if (requiresExplicitCompassPermission && !compassEnabled) { - return { - label: 'Enable Compass First', - title: 'Compass access is not enabled yet. Tap Enable Compass first.', - isError: true - }; - } - - if (!hasSeenOrientationEvent) { - return { - label: 'No Sensor Data', - title: 'No orientation sensor data has been detected on this device. This setup likely has no compass/motion sensor available (common on desktop/laptop hardware).', - isError: true - }; - } - - if (!hasSeenHeadingValue) { - return { - label: 'Heading Unavailable', - title: 'Orientation events are arriving, but compass heading is unavailable in this browser/device context.', - isError: true - }; - } - - return { - label: 'Sensor Warming Up', - title: 'Heading sensor data is not ready yet. Try again in a moment.', - isError: false - }; -}; - -const clearCompassButtonStatus = () => { - if (compassStatusTimer) { - clearTimeout(compassStatusTimer); - compassStatusTimer = null; - } - - compassButtonStatusLabel = ''; - compassButtonStatusTitle = ''; - compassButtonStatusError = false; -}; - -const clearCompassProbeTimer = () => { - if (!compassProbeTimer) return; - clearTimeout(compassProbeTimer); - compassProbeTimer = null; -}; - -const scheduleCompassProbe = () => { - clearCompassProbeTimer(); - - if (compassDefinitelyUnsupported) return; - if (!compassEnabled) return; - if (hasSeenOrientationEvent || hasSeenHeadingValue) return; - - compassProbeTimer = setTimeout(() => { - if (hasSeenOrientationEvent || hasSeenHeadingValue) return; - - const status = getHeadingUnavailableStatus(); - showCompassButtonStatus({ - label: status.label, - title: status.title, - isError: status.isError - }); - }, COMPASS_PROBE_TIMEOUT_MS); -}; - -const showCompassButtonStatus = ({ label, title = '', isError = false, durationMs = 0 }) => { - if (compassStatusTimer) { - clearTimeout(compassStatusTimer); - compassStatusTimer = null; - } - - compassButtonStatusLabel = label; - compassButtonStatusTitle = title; - compassButtonStatusError = isError; - - if (durationMs > 0) { - compassStatusTimer = setTimeout(() => { - clearCompassButtonStatus(); - updateCompassButtonState(); - }, durationMs); - } - - updateCompassButtonState(); -}; - -const updateCompassButtonState = () => { - if (!calibrateHeadingLink) return; - - if (compassDefinitelyUnsupported) { - calibrateHeadingLink.textContent = 'Compass Unavailable'; - calibrateHeadingLink.classList.add('compass-required'); - calibrateHeadingLink.classList.remove('compass-error'); - calibrateHeadingLink.title = getHeadingUnavailableMessage(); - calibrateHeadingLink.disabled = true; - return; - } - - calibrateHeadingLink.disabled = false; - calibrateHeadingLink.textContent = compassButtonStatusLabel || (compassEnabled ? 'Calibrate North' : 'Enable Compass'); - calibrateHeadingLink.title = compassButtonStatusTitle; - calibrateHeadingLink.classList.toggle('compass-required', !compassEnabled); - calibrateHeadingLink.classList.toggle('compass-error', compassButtonStatusError); -}; - -const requestCompassPermission = async () => { - if (!requiresExplicitCompassPermission) { - return true; - } - - try { - const permissionState = await DeviceOrientationEvent.requestPermission(); - return permissionState === 'granted'; - } catch (err) { - console.error('Failed to request compass permission', err); - return false; - } -}; - -updateCompassButtonState(); -scheduleCompassProbe(); - -if (calibrateHeadingLink) { - calibrateHeadingLink.addEventListener('click', async (event) => { - event.preventDefault(); - - if (!compassEnabled) { - const granted = await requestCompassPermission(); - if (!granted) { - showCompassButtonStatus({ - label: 'Compass Denied', - title: 'Compass permission was denied. Enable Motion & Orientation Access in Safari settings and try again.', - isError: true - }); - return; - } - - compassEnabled = true; - clearCompassButtonStatus(); - updateCompassButtonState(); - scheduleCompassProbe(); - showCompassButtonStatus({ - label: 'Compass Enabled', - title: 'Compass access is active. You can now calibrate north.', - durationMs: 1500 - }); - return; - } - - if (lastRawHeading === null) { - const status = getHeadingUnavailableStatus(); - showCompassButtonStatus({ - label: status.label, - title: status.title, - isError: status.isError, - durationMs: status.isError ? 0 : 1500 - }); - return; - } - - // Make current phone-forward direction equal to north (0 deg). - headingCorrectionDeg = normalizeSignedAngle(-lastRawHeading); - localStorage.setItem(HEADING_CORRECTION_STORAGE_KEY, String(headingCorrectionDeg)); - selfHeading = normalizeAngle(lastRawHeading + headingCorrectionDeg); - - showCompassButtonStatus({ - label: 'North Calibrated', - title: 'Calibration applied.', - durationMs: 1500 - }); - requestRender(); - }); -} - -const NAME_STORAGE_KEY = 'north-star-self-name-v2'; -const COLOR_STORAGE_KEY = 'north-star-self-color'; -const PARTICIPANT_VISIBILITY_STORAGE_KEY = 'north-star-participant-visibility'; -const PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY = 'north-star-participants-panel-visible'; -const PARTICIPANT_ORDER_STORAGE_KEY = 'north-star-participant-order'; -const NORTH_MARKER_ID = 'north-marker'; -const GUARDIAN_ANGEL_ID = 'test'; -const normalizeName = (value) => (value || '').trim().slice(0, 32); -const normalizeColor = (value) => { - const candidate = (value || '').trim().toLowerCase(); - return /^#[0-9a-f]{6}$/.test(candidate) ? candidate : ''; -}; -const getReadableTextColor = (hexColor) => { - const normalized = normalizeColor(hexColor); - if (!normalized) return '#0f172a'; - - const r = parseInt(normalized.slice(1, 3), 16); - const g = parseInt(normalized.slice(3, 5), 16); - const b = parseInt(normalized.slice(5, 7), 16); - const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; - - return luminance > 145 ? '#0f172a' : '#f8fafc'; -}; - -// In dark mode, swap colors that are too dark to see on a dark background to white. -const getArrowDisplayColor = (hexColor) => { - if (!document.body.classList.contains('dark')) return hexColor; - const normalized = normalizeColor(hexColor); - if (!normalized) return '#ffffff'; - const r = parseInt(normalized.slice(1, 3), 16) / 255; - const g = parseInt(normalized.slice(3, 5), 16) / 255; - const b = parseInt(normalized.slice(5, 7), 16) / 255; - const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; - return luminance < 0.2 ? '#ffffff' : hexColor; -}; - -const getDeviceHint = async () => { - // Try high-entropy UA data first (Chromium only). - if (navigator.userAgentData) { - try { - const data = await navigator.userAgentData.getHighEntropyValues(['model']); - if (data.model) return data.model; - } catch { - // Fall through to UA string parsing. - } - } - - const ua = navigator.userAgent; - // iPhone / iPad - const iosMatch = ua.match(/\(([^)]+)\)/); - if (/iPhone/.test(ua)) return 'iPhone'; - if (/iPad/.test(ua)) return 'iPad'; - // Named Android device (e.g. "SM-G991B Build/..." or "Pixel 7 Build/...") - const androidModel = ua.match(/;\s*([^;]+?)\s+Build\//); - if (androidModel) return androidModel[1]; - // OS family fallback - if (/Android/.test(ua)) return 'Android'; - if (/Mac/.test(ua)) return 'Mac'; - if (/Windows/.test(ua)) return 'PC'; - if (/Linux/.test(ua)) return 'Linux'; - return null; -}; - -const createRandomUserName = async () => { - const hint = await getDeviceHint(); - const suffix = Math.floor(1000 + Math.random() * 9000); - return hint ? `${hint} ${suffix}` : `User ${suffix}`; -}; - -const createRandomColor = () => `#${Math.floor(Math.random() * 0x1000000).toString(16).padStart(6, '0')}`; -const resolveInitialSelfName = async () => { - const savedName = normalizeName(localStorage.getItem(NAME_STORAGE_KEY)); - - if (savedName) return savedName; - - const generatedName = await createRandomUserName(); - localStorage.setItem(NAME_STORAGE_KEY, generatedName); - return generatedName; -}; -const resolveInitialSelfColor = () => { - const savedColor = normalizeColor(localStorage.getItem(COLOR_STORAGE_KEY)); - - if (savedColor) return savedColor; - - const generatedColor = createRandomColor(); - localStorage.setItem(COLOR_STORAGE_KEY, generatedColor); - return generatedColor; -}; - -const initialSelfName = await resolveInitialSelfName(); -const initialSelfColor = resolveInitialSelfColor(); -const participantVisibility = (() => { - try { - const raw = localStorage.getItem(PARTICIPANT_VISIBILITY_STORAGE_KEY); - return raw ? JSON.parse(raw) : {}; - } catch { - return {}; - } -})(); -let participantOrder = (() => { - try { - const raw = localStorage.getItem(PARTICIPANT_ORDER_STORAGE_KEY); - const parsed = raw ? JSON.parse(raw) : []; - return Array.isArray(parsed) ? parsed.filter(id => typeof id === 'string') : []; - } catch { - return []; - } -})(); - -const saveParticipantOrder = () => { - localStorage.setItem(PARTICIPANT_ORDER_STORAGE_KEY, JSON.stringify(participantOrder)); -}; - -const getParticipantDefaultRank = (participantId) => { - if (participantId === NORTH_MARKER_ID) return 0; - if (participantId === GUARDIAN_ANGEL_ID || participantId === 'guardian-angel') return 1; - return 2; -}; - -const shouldShowMapLink = (participantId) => { - return participantId !== NORTH_MARKER_ID && participantId !== GUARDIAN_ANGEL_ID; -}; - -const ensureParticipantOrder = (participantsToRender) => { - const ids = participantsToRender.map(participant => participant.id); - const idSet = new Set(ids); - const preserved = participantOrder.filter(id => idSet.has(id)); - const preservedSet = new Set(preserved); - const missing = ids.filter(id => !preservedSet.has(id)); - - missing.sort((a, b) => { - const rankDelta = getParticipantDefaultRank(a) - getParticipantDefaultRank(b); - if (rankDelta !== 0) return rankDelta; - return a.localeCompare(b); - }); - - const nextOrder = [...preserved, ...missing]; - const changed = - nextOrder.length !== participantOrder.length || - nextOrder.some((id, index) => id !== participantOrder[index]); - - if (changed) { - participantOrder = nextOrder; - saveParticipantOrder(); - } - - return participantOrder; -}; -const sortParticipantsForDisplay = (participantsToRender) => { - const order = ensureParticipantOrder(participantsToRender); - const orderIndex = new Map(order.map((id, index) => [id, index])); - - return [...participantsToRender].sort((a, b) => { - const aIndex = orderIndex.get(a.id); - const bIndex = orderIndex.get(b.id); - return aIndex - bIndex; - }); -}; - -let draggedParticipantId = null; -let draggedPointerId = null; -let dropTargetParticipantId = null; -let dropAfterTarget = false; - -const clearParticipantDragVisuals = () => { - if (!participantsPanel) return; - - participantsPanel.querySelectorAll('.participant-row').forEach((row) => { - row.classList.remove('is-dragging', 'drag-over-before', 'drag-over-after'); - }); -}; - -const moveParticipantToIndex = (participantId, targetIndex) => { - const fromIndex = participantOrder.indexOf(participantId); - if (fromIndex < 0) return false; - - const boundedIndex = Math.max(0, Math.min(targetIndex, participantOrder.length - 1)); - if (fromIndex === boundedIndex) return false; - - participantOrder.splice(fromIndex, 1); - participantOrder.splice(boundedIndex, 0, participantId); - saveParticipantOrder(); - return true; -}; - -const endParticipantDrag = () => { - draggedParticipantId = null; - draggedPointerId = null; - dropTargetParticipantId = null; - dropAfterTarget = false; - clearParticipantDragVisuals(); -}; - -const getInitialParticipantsPanelVisible = () => { - const saved = localStorage.getItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY); - if (saved === 'true' || saved === 'false') { - return saved === 'true'; - } - - return !window.matchMedia('(max-width: 720px)').matches; -}; - -let participantsPanelVisible = getInitialParticipantsPanelVisible(); -const applyParticipantsPanelVisibility = () => { - if (participantsPanel) { - participantsPanel.classList.toggle('is-hidden', !participantsPanelVisible); - } - if (participantsToggle) { - participantsToggle.textContent = participantsPanelVisible ? 'Hide Participants' : 'Show Participants'; - participantsToggle.setAttribute('aria-expanded', String(participantsPanelVisible)); - } - - localStorage.setItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY, String(participantsPanelVisible)); -}; - -if (participantsToggle) { - participantsToggle.addEventListener('click', () => { - participantsPanelVisible = !participantsPanelVisible; - applyParticipantsPanelVisibility(); - }); -} - -document.addEventListener('pointerdown', (event) => { - if (!participantsPanelVisible) return; - - const target = event.target; - if (!(target instanceof Node)) return; - - const clickedInsidePanel = participantsPanel ? participantsPanel.contains(target) : false; - const clickedToggle = participantsToggle ? participantsToggle.contains(target) : false; - - if (clickedInsidePanel || clickedToggle) return; - - participantsPanelVisible = false; - applyParticipantsPanelVisibility(); -}); - -document.addEventListener('pointermove', (event) => { - if (!draggedParticipantId || event.pointerId !== draggedPointerId) return; - - event.preventDefault(); - - const pointed = document.elementFromPoint(event.clientX, event.clientY); - if (!(pointed instanceof Element) || !participantsPanel) { - dropTargetParticipantId = null; - dropAfterTarget = false; - clearParticipantDragVisuals(); - return; - } - - const targetRow = pointed.closest('.participant-row'); - clearParticipantDragVisuals(); - - const draggedRow = participantsPanel.querySelector(`[data-participant-id="${draggedParticipantId}"]`); - if (draggedRow) { - draggedRow.classList.add('is-dragging'); - } - - if (!(targetRow instanceof HTMLElement) || !participantsPanel.contains(targetRow)) { - dropTargetParticipantId = null; - dropAfterTarget = false; - return; - } - - const targetId = targetRow.dataset.participantId; - if (!targetId || targetId === draggedParticipantId) { - dropTargetParticipantId = null; - dropAfterTarget = false; - return; - } - - const rect = targetRow.getBoundingClientRect(); - const after = event.clientY > rect.top + rect.height / 2; - - dropTargetParticipantId = targetId; - dropAfterTarget = after; - targetRow.classList.add(after ? 'drag-over-after' : 'drag-over-before'); -}); - -document.addEventListener('pointerup', (event) => { - if (!draggedParticipantId || event.pointerId !== draggedPointerId) return; - - const draggedId = draggedParticipantId; - const targetId = dropTargetParticipantId; - const insertAfter = dropAfterTarget; - endParticipantDrag(); - - if (!targetId) return; - - const targetIndex = participantOrder.indexOf(targetId); - if (targetIndex < 0) return; - - let nextIndex = insertAfter ? targetIndex + 1 : targetIndex; - const currentIndex = participantOrder.indexOf(draggedId); - if (currentIndex >= 0 && currentIndex < nextIndex) { - nextIndex -= 1; - } - - if (!moveParticipantToIndex(draggedId, nextIndex)) return; - - renderParticipantControls(); - requestRender(); -}); - -document.addEventListener('pointercancel', () => { - if (!draggedParticipantId) return; - endParticipantDrag(); +const { roomId, roomKey } = await initSession({ + roomCurrent, + createRoomLink, + inviteRoomQrList, + isLocalCommit, + BASE_ROOM_URL, + GITLAB_PAGES_BASE_URL }); -applyParticipantsPanelVisibility(); - -const isParticipantVisible = (participantId) => participantVisibility[participantId] !== false; -const setParticipantVisibility = (participantId, visible) => { - participantVisibility[participantId] = visible; - localStorage.setItem(PARTICIPANT_VISIBILITY_STORAGE_KEY, JSON.stringify(participantVisibility)); -}; - -const selfParticipant = { - id: uid, - name: initialSelfName, - color: initialSelfColor, - position: selfPosition, - timestamp: Date.now() -}; - const participantRef = ref(db, `rooms/${roomId}/participants/${uid}`); onDisconnect(participantRef).remove(); -async function syncSelfParticipant() { +// selfParticipant is assigned below by initSelfProfile; syncSelfParticipant is +// only ever called after that point (from event handlers / intervals). +let selfParticipant; + +const syncSelfParticipant = async () => { selfParticipant.timestamp = Date.now(); - let dbRecord; - if (roomKey) { - dbRecord = { - encryptedState: await encryptJson(roomKey, selfParticipant) - }; - } else { - dbRecord = selfParticipant; - } - console.debug('Syncing self participant:', dbRecord); + const dbRecord = roomKey + ? { encryptedState: await encryptJson(roomKey, selfParticipant) } + : selfParticipant; try { await set(participantRef, dbRecord); } catch (err) { console.error('Error syncing participant in database', err); } -} - -let renderFrameRequested = false; -const requestRender = () => { - if (renderFrameRequested) { - return; - } - - renderFrameRequested = true; - requestAnimationFrame(() => { - renderFrameRequested = false; - render(); - }); }; let lastLocationSyncAt = 0; const maybeSyncLocation = () => { const now = Date.now(); - if (now - lastLocationSyncAt < LOCATION_SYNC_INTERVAL_MS) { - return; - } - + if (now - lastLocationSyncAt < LOCATION_SYNC_INTERVAL_MS) return; lastLocationSyncAt = now; void syncSelfParticipant(); }; -if (selfNameInput) { - selfNameInput.value = selfParticipant.name; -} -if (selfColorInput) { - selfColorInput.value = selfParticipant.color; -} - -const applySelfName = async (rawName) => { - const nextName = normalizeName(rawName) || await createRandomUserName(); - - if (nextName === selfParticipant.name) { - if (selfNameInput) selfNameInput.value = nextName; - return; - } - - selfParticipant.name = nextName; - localStorage.setItem(NAME_STORAGE_KEY, nextName); - if (selfNameInput) selfNameInput.value = nextName; - await syncSelfParticipant(); - requestRender(); -}; - -const applySelfColor = async (rawColor) => { - const nextColor = normalizeColor(rawColor) || createRandomColor(); - - if (nextColor === selfParticipant.color) { - if (selfColorInput) selfColorInput.value = nextColor; - return; - } - - selfParticipant.color = nextColor; - localStorage.setItem(COLOR_STORAGE_KEY, nextColor); - if (selfColorInput) selfColorInput.value = nextColor; - await syncSelfParticipant(); - requestRender(); +let renderFrameRequested = false; +const requestRender = () => { + if (renderFrameRequested) return; + renderFrameRequested = true; + requestAnimationFrame(() => { + renderFrameRequested = false; + render(); + }); }; -if (nameForm) { - nameForm.addEventListener('submit', async (event) => { - event.preventDefault(); - await applySelfName(selfNameInput ? selfNameInput.value : selfParticipant.name); - await applySelfColor(selfColorInput ? selfColorInput.value : selfParticipant.color); - if (selfNameInput) selfNameInput.blur(); - }); -} +({ selfParticipant } = await initSelfProfile({ + nameForm, + selfNameInput, + selfColorInput, + uid, + selfPosition, + syncSelf: syncSelfParticipant, + requestRender +})); + +const compassController = initCompassController({ + calibrateHeadingLink, + initialHeadingCorrectionDeg: headingCorrectionDeg, + onHeadingCorrectionDegChange: (next) => { headingCorrectionDeg = next; }, + requestRender +}); -if (selfNameInput) { - selfNameInput.addEventListener('change', async () => { - await applySelfName(selfNameInput.value); - }); -} -if (selfColorInput) { - selfColorInput.addEventListener('change', async () => { - await applySelfColor(selfColorInput.value); - }); -} +const participantsController = createParticipantsPanelController({ + participantsPanel, + participantsToggle, + requestRender, + getReadableTextColor, + northMarkerId: NORTH_MARKER_ID, + guardianAngelId: GUARDIAN_ANGEL_ID, + selfParticipant, + selfPosition +}); navigator.geolocation.watchPosition(async (pos) => { selfPosition.lat = pos.coords.latitude; @@ -963,327 +153,41 @@ navigator.geolocation.watchPosition(async (pos) => { }, (err) => { console.error('Error getting geolocation', err); }); -const getScreenOrientationAngle = () => { - if (window.screen?.orientation && typeof window.screen.orientation.angle === 'number') { - return window.screen.orientation.angle; - } - if (typeof window.orientation === 'number') { - return window.orientation; - } - return 0; -}; - -const getHeadingFromEvent = (orientationEvent) => { - // iOS Safari: already compass heading of the top of the screen. - if (typeof orientationEvent.webkitCompassHeading === 'number') { - return normalizeAngle(orientationEvent.webkitCompassHeading); - } - - if (orientationEvent.alpha === null) { - return null; - } - - // Generic alpha -> compass heading, then compensate screen rotation. - const alphaHeading = normalizeAngle(360 - orientationEvent.alpha); - return normalizeAngle(alphaHeading + getScreenOrientationAngle()); -}; - -let hasAbsoluteOrientation = false; - -window.addEventListener('deviceorientationabsolute', (event) => { - clearCompassProbeTimer(); - hasSeenOrientationEvent = true; - const heading = getHeadingFromEvent(event); - if (heading === null) return; - - if (!compassEnabled) { - compassEnabled = true; - updateCompassButtonState(); - } - - hasSeenHeadingValue = true; - if (compassButtonStatusError) { - clearCompassButtonStatus(); - updateCompassButtonState(); - } - hasAbsoluteOrientation = true; - lastRawHeading = heading; - selfHeading = normalizeAngle(heading + headingCorrectionDeg); - requestRender(); -}); - -window.addEventListener('deviceorientation', (event) => { - if (hasAbsoluteOrientation) return; - - clearCompassProbeTimer(); - hasSeenOrientationEvent = true; - - const heading = getHeadingFromEvent(event); - if (heading === null) return; - - if (!compassEnabled) { - compassEnabled = true; - updateCompassButtonState(); - } - - hasSeenHeadingValue = true; - if (compassButtonStatusError) { - clearCompassButtonStatus(); - updateCompassButtonState(); - } - lastRawHeading = heading; - selfHeading = normalizeAngle(heading + headingCorrectionDeg); - requestRender(); -}); document.addEventListener('visibilitychange', () => { if (document.visibilityState !== 'visible') return; - scheduleCompassProbe(); + compassController.scheduleCompassProbe(); void requestWakeLock(); }); void requestWakeLock(); -const participantsRef = ref(db, `rooms/${roomId}/participants`); -// onValue(participantsRef, (snapshot) => { -// const participants = snapshot.val(); // object of uid → participant -// console.log(participants); -// // update arrows in your UI here -// }); -const participants = []; -let participantsSnapshotVersion = 0; -onValue(participantsRef, async (snapshot) => { - const snapshotVersion = ++participantsSnapshotVersion; - console.debug('Participants data updated:', snapshot.val()); - - const participantsData = snapshot.val(); // object of uid → participant - if (participantsData) { - const rawParticipants = Object.values(participantsData); - const decrypted = await Promise.all(rawParticipants.map(async (p) => { - if (p.encryptedState && roomKey) { - try { - return await decryptJson(roomKey, p.encryptedState); - } catch (err) { - console.error('Failed to decrypt participant state', p.id, err); - return null; // exclude participant whose data can't be decrypted - } - } - if (p.encryptedPosition && roomKey) { - try { - const position = await decryptJson(roomKey, p.encryptedPosition); - const { encryptedPosition: _stripped, ...rest } = p; - return { ...rest, position }; - } catch (err) { - console.error('Failed to decrypt legacy participant position', p.id, err); - return null; - } - } - return p; // plaintext fallback for rooms without a key - })); - if (snapshotVersion !== participantsSnapshotVersion) return; - participants.length = 0; - participants.push(...decrypted.filter(Boolean)); - } else { - if (snapshotVersion !== participantsSnapshotVersion) return; - console.warn('No participants data'); - participants.length = 0; +const { participants } = subscribeToParticipants({ + db, + roomId, + roomKey, + onUpdate: (updated) => { + const participantsToRender = participantsController.getParticipantsToRender(updated); + participantsController.renderParticipantControls(participantsToRender); + requestRender(); } - - console.debug('Updated participants array:', participants); - renderParticipantControls(); - requestRender(); }); -const renderParticipantControls = () => { - const participantsToRender = getParticipantsToRender(); - - participantsPanel.textContent = ''; - const heading = document.createElement('h3'); - heading.textContent = 'Participants'; - participantsPanel.appendChild(heading); - - for (const participant of participantsToRender) { - const row = document.createElement('div'); - row.className = 'participant-row'; - row.dataset.participantId = participant.id; - - const checkbox = document.createElement('input'); - checkbox.type = 'checkbox'; - checkbox.checked = isParticipantVisible(participant.id); - checkbox.addEventListener('change', () => { - setParticipantVisibility(participant.id, checkbox.checked); - }); - - const nameWrapper = document.createElement('div'); - nameWrapper.className = 'participant-name-badge'; - nameWrapper.style.backgroundColor = participant.color; - nameWrapper.style.color = getReadableTextColor(participant.color); - - const nameText = document.createElement('span'); - nameText.className = 'participant-name'; - nameText.textContent = participant.name; - - nameWrapper.appendChild(nameText); - - const mapLink = document.createElement('a'); - mapLink.className = 'participant-map-link'; - mapLink.href = `https://www.google.com/maps?q=${participant.position.lat},${participant.position.lon}`; - mapLink.target = '_blank'; - mapLink.rel = 'noopener noreferrer'; - mapLink.textContent = '📍'; - mapLink.title = `Open ${participant.name} in Google Maps`; - mapLink.setAttribute('aria-label', `Open ${participant.name} in Google Maps`); - - row.appendChild(checkbox); - if (shouldShowMapLink(participant.id)) { - nameWrapper.appendChild(mapLink); - } - - const dragHandle = document.createElement('button'); - dragHandle.type = 'button'; - dragHandle.className = 'participant-drag-handle'; - dragHandle.textContent = '☰'; - dragHandle.title = `Drag to reorder ${participant.name}`; - dragHandle.setAttribute('aria-label', `Drag to reorder ${participant.name}`); - dragHandle.addEventListener('pointerdown', (event) => { - if (event.button !== undefined && event.button !== 0) return; - - event.preventDefault(); - draggedParticipantId = participant.id; - draggedPointerId = event.pointerId; - dropTargetParticipantId = null; - dropAfterTarget = false; - - clearParticipantDragVisuals(); - row.classList.add('is-dragging'); - }); - - row.appendChild(dragHandle); - row.appendChild(nameWrapper); - participantsPanel.appendChild(row); - } -}; - -let lastSAngle = null; -const getParticipantsToRender = () => { - const participantsToRender = participants.filter(p => p.id !== selfParticipant.id); - - // Add a synthetic North marker relative to the current self position for heading validation. - if (participantsToRender.findIndex(p => p.id === NORTH_MARKER_ID) === -1) { - participantsToRender.push({ - id: NORTH_MARKER_ID, - name: 'North', - color: '#000000', - position: { - lat: selfPosition.lat + 0.001, - lon: selfPosition.lon - }, - timestamp: Date.now() - }); - } - - // For testing, add a "stalker" participant that moves in a circle around the self participant. This is useful for testing the arrow rotation without needing multiple devices. The stalker will NOT be added as a participant in the database, so it will show up only on the local device and won't affect other participants. - const stalkerAngle = (Date.now() / 10000) % (2 * Math.PI); // angle in radians that changes over time - // console.debug('Stalker angle (radians):', stalkerAngle); - const stalkerDistance = 0.001; // distance in degrees (about 111 meters) - const stalkerPosition = { - lat: selfPosition.lat + stalkerDistance * Math.cos(stalkerAngle), - lon: selfPosition.lon + stalkerDistance * Math.sin(stalkerAngle) - }; - const testParticipant = { - id: GUARDIAN_ANGEL_ID, - name: 'Guardian Angel', - color: '#ff0000', - position: stalkerPosition, - timestamp: Date.now() - }; - // participantsToRender.push(testParticipant); - - return sortParticipantsForDisplay(participantsToRender); -}; - const render = () => { - const participantsToRender = getParticipantsToRender(); - const content = document.getElementById('content'); - const activeArrowIds = new Set( - participantsToRender - .filter(target => target.id !== selfParticipant.id) - .map(target => `arrow-${target.id}`) - ); - - // For each participant, calculate the angle from self to the participant, and rotate the arrow accordingly. Also set the color of the arrow to the participant's color. - // Skip self participant, we don't want to render an arrow for ourselves. - for (const target of participantsToRender) { - if (target.id === selfParticipant.id) continue; - - // create a new arrow element if it doesn't exist - // copy from 'default-arrow' element, but set id to 'arrow-PARTICIPANT_ID' and display to block - let existingArrow = document.getElementById('arrow-' + target.id); - if (!existingArrow) { - const newArrow = arrow.cloneNode(true); - newArrow.id = 'arrow-' + target.id; - newArrow.style.display = 'block'; - newArrow.currentAngle = null; // track the current angle for smooth rotation - - const arrowLabelAnchor = document.createElement('div'); - arrowLabelAnchor.className = 'arrow-label-anchor'; - - const arrowLabel = document.createElement('span'); - arrowLabel.className = 'arrow-label'; - arrowLabel.textContent = target.name; - arrowLabelAnchor.appendChild(arrowLabel); - newArrow.appendChild(arrowLabelAnchor); - - content.appendChild(newArrow); - existingArrow = newArrow; - } - - // Keep DOM order in sync with participant order so stacking matches the list. - content.appendChild(existingArrow); - - if (!isParticipantVisible(target.id)) { - existingArrow.style.display = 'none'; - continue; - } - - existingArrow.style.display = 'block'; - - // Rotate the arrow toward the other participant and use their color. - const dx = target.position.lon - selfParticipant.position.lon; - const dy = target.position.lat - selfParticipant.position.lat; - const rawAngle = Math.atan2(dx, dy) * (180 / Math.PI); - - // Consider the heading of the device to rotate the arrow accordingly - const targetAngle = normalizeAngle(rawAngle - selfHeading); - - let currentArrowAngle = existingArrow.currentAngle; - if (currentArrowAngle === null) { - currentArrowAngle = targetAngle; - } else { - currentArrowAngle += getShortestDelta(currentArrowAngle, targetAngle) * 0.1; // smooth the rotation - } - - existingArrow.currentAngle = currentArrowAngle; - existingArrow.style.transform = `rotate(${currentArrowAngle}deg)`; - existingArrow.style.color = getArrowDisplayColor(target.color); - - const arrowLabel = existingArrow.querySelector('.arrow-label'); - if (arrowLabel) { - arrowLabel.textContent = target.name; - arrowLabel.style.transform = `rotate(${-currentArrowAngle}deg)`; - } - } - - // Remove arrows for participants who are no longer present. - content.querySelectorAll('.arrow[id^="arrow-"]').forEach((arrowElement) => { - if (!activeArrowIds.has(arrowElement.id)) { - arrowElement.remove(); - } + const participantsToRender = participantsController.getParticipantsToRender(participants); + + renderArrows({ + content, + arrow, + participantsToRender, + selfParticipant, + selfHeading: compassController.getSelfHeading(), + isParticipantVisible: participantsController.isParticipantVisible, + getArrowDisplayColor }); - // output debug info logDebug(`Real participants: ${participants.length} | Commit: ${isLocalCommit ? 'local' : `<a href="https://gitlab.com/PavelSafronov/north-star/-/commit/${GIT_COMMIT}" target="_blank">${GIT_COMMIT}</a>`} | Repo: <a href="https://gitlab.com/PavelSafronov/north-star" target="_blank">north-star</a>`); }; await syncSelfParticipant(); -setInterval(render, RENDER_INTERVAL_MS); // capped render loop for smoother mobile performance \ No newline at end of file +setInterval(render, RENDER_INTERVAL_MS); diff --git a/src/compass.js b/src/compass.js new file mode 100644 index 0000000..744db0f --- /dev/null +++ b/src/compass.js @@ -0,0 +1,312 @@ +import { normalizeAngle, normalizeSignedAngle } from './utils.js'; + +export const initCompassController = ({ + calibrateHeadingLink, + initialHeadingCorrectionDeg, + onHeadingCorrectionDegChange, + requestRender +}) => { + const HEADING_CORRECTION_STORAGE_KEY = 'north-star-heading-correction-deg'; + + const requiresExplicitCompassPermission = + typeof DeviceOrientationEvent !== 'undefined' && + typeof DeviceOrientationEvent.requestPermission === 'function'; + const hasDeviceOrientationSupport = typeof DeviceOrientationEvent !== 'undefined'; + const compassDefinitelyUnsupported = !hasDeviceOrientationSupport; + + let headingCorrectionDeg = initialHeadingCorrectionDeg; + let selfHeading = 0; + let lastRawHeading = null; + + let compassEnabled = !requiresExplicitCompassPermission; + let hasSeenOrientationEvent = false; + let hasSeenHeadingValue = false; + let compassButtonStatusLabel = ''; + let compassButtonStatusTitle = ''; + let compassButtonStatusError = false; + let compassStatusTimer = null; + let compassProbeTimer = null; + let hasAbsoluteOrientation = false; + + const COMPASS_PROBE_TIMEOUT_MS = 2500; + + const getHeadingUnavailableMessage = () => { + if (compassDefinitelyUnsupported) { + return 'This browser/device does not expose orientation sensors, so a compass is unavailable here.'; + } + + if (requiresExplicitCompassPermission && !compassEnabled) { + return 'Compass access is not enabled yet. Tap Enable Compass first.'; + } + + if (!hasSeenOrientationEvent) { + return 'No orientation sensor data has been detected on this device. This setup likely has no compass/motion sensor available (common on desktop/laptop hardware).'; + } + + if (!hasSeenHeadingValue) { + return 'Orientation events are arriving, but compass heading is unavailable in this browser/device context.'; + } + + return 'Heading sensor data is not ready yet. Try again in a moment.'; + }; + + const getHeadingUnavailableStatus = () => { + if (compassDefinitelyUnsupported) { + return { + label: 'Compass Unavailable', + title: 'This browser/device does not expose orientation sensors, so a compass is unavailable here.', + isError: true + }; + } + + if (requiresExplicitCompassPermission && !compassEnabled) { + return { + label: 'Enable Compass First', + title: 'Compass access is not enabled yet. Tap Enable Compass first.', + isError: true + }; + } + + if (!hasSeenOrientationEvent) { + return { + label: 'No Sensor Data', + title: 'No orientation sensor data has been detected on this device. This setup likely has no compass/motion sensor available (common on desktop/laptop hardware).', + isError: true + }; + } + + if (!hasSeenHeadingValue) { + return { + label: 'Heading Unavailable', + title: 'Orientation events are arriving, but compass heading is unavailable in this browser/device context.', + isError: true + }; + } + + return { + label: 'Sensor Warming Up', + title: 'Heading sensor data is not ready yet. Try again in a moment.', + isError: false + }; + }; + + const clearCompassButtonStatus = () => { + if (compassStatusTimer) { + clearTimeout(compassStatusTimer); + compassStatusTimer = null; + } + + compassButtonStatusLabel = ''; + compassButtonStatusTitle = ''; + compassButtonStatusError = false; + }; + + const clearCompassProbeTimer = () => { + if (!compassProbeTimer) return; + clearTimeout(compassProbeTimer); + compassProbeTimer = null; + }; + + const showCompassButtonStatus = ({ label, title = '', isError = false, durationMs = 0 }) => { + if (compassStatusTimer) { + clearTimeout(compassStatusTimer); + compassStatusTimer = null; + } + + compassButtonStatusLabel = label; + compassButtonStatusTitle = title; + compassButtonStatusError = isError; + + if (durationMs > 0) { + compassStatusTimer = setTimeout(() => { + clearCompassButtonStatus(); + updateCompassButtonState(); + }, durationMs); + } + + updateCompassButtonState(); + }; + + const updateCompassButtonState = () => { + if (!calibrateHeadingLink) return; + + if (compassDefinitelyUnsupported) { + calibrateHeadingLink.textContent = 'Compass Unavailable'; + calibrateHeadingLink.classList.add('compass-required'); + calibrateHeadingLink.classList.remove('compass-error'); + calibrateHeadingLink.title = getHeadingUnavailableMessage(); + calibrateHeadingLink.disabled = true; + return; + } + + calibrateHeadingLink.disabled = false; + calibrateHeadingLink.textContent = compassButtonStatusLabel || (compassEnabled ? 'Calibrate North' : 'Enable Compass'); + calibrateHeadingLink.title = compassButtonStatusTitle; + calibrateHeadingLink.classList.toggle('compass-required', !compassEnabled); + calibrateHeadingLink.classList.toggle('compass-error', compassButtonStatusError); + }; + + const scheduleCompassProbe = () => { + clearCompassProbeTimer(); + + if (compassDefinitelyUnsupported) return; + if (!compassEnabled) return; + if (hasSeenOrientationEvent || hasSeenHeadingValue) return; + + compassProbeTimer = setTimeout(() => { + if (hasSeenOrientationEvent || hasSeenHeadingValue) return; + + const status = getHeadingUnavailableStatus(); + showCompassButtonStatus({ + label: status.label, + title: status.title, + isError: status.isError + }); + }, COMPASS_PROBE_TIMEOUT_MS); + }; + + const requestCompassPermission = async () => { + if (!requiresExplicitCompassPermission) { + return true; + } + + try { + const permissionState = await DeviceOrientationEvent.requestPermission(); + return permissionState === 'granted'; + } catch (err) { + console.error('Failed to request compass permission', err); + return false; + } + }; + + const getScreenOrientationAngle = () => { + if (window.screen?.orientation && typeof window.screen.orientation.angle === 'number') { + return window.screen.orientation.angle; + } + if (typeof window.orientation === 'number') { + return window.orientation; + } + return 0; + }; + + const getHeadingFromEvent = (orientationEvent) => { + if (typeof orientationEvent.webkitCompassHeading === 'number') { + return normalizeAngle(orientationEvent.webkitCompassHeading); + } + + if (orientationEvent.alpha === null) { + return null; + } + + const alphaHeading = normalizeAngle(360 - orientationEvent.alpha); + return normalizeAngle(alphaHeading + getScreenOrientationAngle()); + }; + + updateCompassButtonState(); + scheduleCompassProbe(); + + if (calibrateHeadingLink) { + calibrateHeadingLink.addEventListener('click', async (event) => { + event.preventDefault(); + + if (!compassEnabled) { + const granted = await requestCompassPermission(); + if (!granted) { + showCompassButtonStatus({ + label: 'Compass Denied', + title: 'Compass permission was denied. Enable Motion & Orientation Access in Safari settings and try again.', + isError: true + }); + return; + } + + compassEnabled = true; + clearCompassButtonStatus(); + updateCompassButtonState(); + scheduleCompassProbe(); + showCompassButtonStatus({ + label: 'Compass Enabled', + title: 'Compass access is active. You can now calibrate north.', + durationMs: 1500 + }); + return; + } + + if (lastRawHeading === null) { + const status = getHeadingUnavailableStatus(); + showCompassButtonStatus({ + label: status.label, + title: status.title, + isError: status.isError, + durationMs: status.isError ? 0 : 1500 + }); + return; + } + + headingCorrectionDeg = normalizeSignedAngle(-lastRawHeading); + localStorage.setItem(HEADING_CORRECTION_STORAGE_KEY, String(headingCorrectionDeg)); + onHeadingCorrectionDegChange(headingCorrectionDeg); + selfHeading = normalizeAngle(lastRawHeading + headingCorrectionDeg); + + showCompassButtonStatus({ + label: 'North Calibrated', + title: 'Calibration applied.', + durationMs: 1500 + }); + requestRender(); + }); + } + + window.addEventListener('deviceorientationabsolute', (event) => { + clearCompassProbeTimer(); + hasSeenOrientationEvent = true; + const heading = getHeadingFromEvent(event); + if (heading === null) return; + + if (!compassEnabled) { + compassEnabled = true; + updateCompassButtonState(); + } + + hasSeenHeadingValue = true; + if (compassButtonStatusError) { + clearCompassButtonStatus(); + updateCompassButtonState(); + } + + hasAbsoluteOrientation = true; + lastRawHeading = heading; + selfHeading = normalizeAngle(heading + headingCorrectionDeg); + requestRender(); + }); + + window.addEventListener('deviceorientation', (event) => { + if (hasAbsoluteOrientation) return; + + clearCompassProbeTimer(); + hasSeenOrientationEvent = true; + + const heading = getHeadingFromEvent(event); + if (heading === null) return; + + if (!compassEnabled) { + compassEnabled = true; + updateCompassButtonState(); + } + + hasSeenHeadingValue = true; + if (compassButtonStatusError) { + clearCompassButtonStatus(); + updateCompassButtonState(); + } + + lastRawHeading = heading; + selfHeading = normalizeAngle(heading + headingCorrectionDeg); + requestRender(); + }); + + return { + getSelfHeading: () => selfHeading, + scheduleCompassProbe + }; +}; diff --git a/src/crypto.js b/src/crypto.js new file mode 100644 index 0000000..35dc69d --- /dev/null +++ b/src/crypto.js @@ -0,0 +1,35 @@ +const AES_PARAMS = { name: 'AES-GCM', length: 256 }; +const IV_BYTES = 12; + +export const generateRoomKey = () => + crypto.subtle.generateKey(AES_PARAMS, true, ['encrypt', 'decrypt']); + +export const exportRoomKey = async (key) => { + const raw = await crypto.subtle.exportKey('raw', key); + return btoa(String.fromCharCode(...new Uint8Array(raw))) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +}; + +export const importRoomKey = async (base64url) => { + const b64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4); + const raw = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); + return crypto.subtle.importKey('raw', raw, AES_PARAMS, false, ['encrypt', 'decrypt']); +}; + +export const encryptJson = async (key, value) => { + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const plaintext = new TextEncoder().encode(JSON.stringify(value)); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext); + const ivB64 = btoa(String.fromCharCode(...iv)); + const ctB64 = btoa(String.fromCharCode(...new Uint8Array(ciphertext))); + return `${ivB64}:${ctB64}`; +}; + +export const decryptJson = async (key, encryptedValue) => { + const [ivB64, ctB64] = encryptedValue.split(':'); + const iv = Uint8Array.from(atob(ivB64), (c) => c.charCodeAt(0)); + const ciphertext = Uint8Array.from(atob(ctB64), (c) => c.charCodeAt(0)); + const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext); + return JSON.parse(new TextDecoder().decode(plaintext)); +}; diff --git a/src/darkmode.js b/src/darkmode.js new file mode 100644 index 0000000..be81bf4 --- /dev/null +++ b/src/darkmode.js @@ -0,0 +1,24 @@ +export const DARK_MODE_STORAGE_KEY = 'north-star-dark-mode'; + +export const initDarkMode = (toggleButton) => { + const applyDarkMode = (dark) => { + document.body.classList.toggle('dark', dark); + if (toggleButton) toggleButton.textContent = dark ? '☀️' : '🌙'; + localStorage.setItem(DARK_MODE_STORAGE_KEY, dark ? '1' : '0'); + }; + + const storedDarkMode = localStorage.getItem(DARK_MODE_STORAGE_KEY); + const prefersDark = storedDarkMode !== null + ? storedDarkMode === '1' + : window.matchMedia('(prefers-color-scheme: dark)').matches; + + applyDarkMode(prefersDark); + + if (toggleButton) { + toggleButton.addEventListener('click', () => { + applyDarkMode(!document.body.classList.contains('dark')); + }); + } + + return { applyDarkMode }; +}; diff --git a/src/firebase.js b/src/firebase.js new file mode 100644 index 0000000..df296f2 --- /dev/null +++ b/src/firebase.js @@ -0,0 +1,27 @@ +import { initializeApp } from 'https://www.gstatic.com/firebasejs/12.10.0/firebase-app.js'; +import { getAuth, signInAnonymously } from 'https://www.gstatic.com/firebasejs/12.10.0/firebase-auth.js'; +import { getDatabase } from 'https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js'; + +const firebaseConfig = { + apiKey: 'AIzaSyA4G7e13Gt1XAIds1Y63X_RFw-2O48iCLM', + authDomain: 'north-star-e5dee.firebaseapp.com', + projectId: 'north-star-e5dee', + storageBucket: 'north-star-e5dee.firebasestorage.app', + messagingSenderId: '999461150314', + appId: '1:999461150314:web:53f3c6216dd4b0468bf413', + databaseURL: 'https://north-star-e5dee-default-rtdb.firebaseio.com/' +}; + +export const initFirebaseSession = async () => { + const app = initializeApp(firebaseConfig); + const auth = getAuth(app); + const db = getDatabase(app); + + const authSignInResponse = await signInAnonymously(auth); + const uid = authSignInResponse.user.uid; + + console.debug('Signed in anonymously:', authSignInResponse); + console.debug('User ID:', uid); + + return { db, uid }; +}; diff --git a/src/participants-panel.js b/src/participants-panel.js new file mode 100644 index 0000000..c814ed4 --- /dev/null +++ b/src/participants-panel.js @@ -0,0 +1,339 @@ +export const createParticipantsPanelController = ({ + participantsPanel, + participantsToggle, + requestRender, + getReadableTextColor, + northMarkerId, + guardianAngelId, + selfParticipant, + selfPosition +}) => { + const PARTICIPANT_VISIBILITY_STORAGE_KEY = 'north-star-participant-visibility'; + const PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY = 'north-star-participants-panel-visible'; + const PARTICIPANT_ORDER_STORAGE_KEY = 'north-star-participant-order'; + + const participantVisibility = (() => { + try { + const raw = localStorage.getItem(PARTICIPANT_VISIBILITY_STORAGE_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } + })(); + + let participantOrder = (() => { + try { + const raw = localStorage.getItem(PARTICIPANT_ORDER_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []; + } catch { + return []; + } + })(); + + const saveParticipantOrder = () => { + localStorage.setItem(PARTICIPANT_ORDER_STORAGE_KEY, JSON.stringify(participantOrder)); + }; + + const getParticipantDefaultRank = (participantId) => { + if (participantId === northMarkerId) return 0; + if (participantId === guardianAngelId || participantId === 'guardian-angel') return 1; + return 2; + }; + + const shouldShowMapLink = (participantId) => { + return participantId !== northMarkerId && participantId !== guardianAngelId; + }; + + const ensureParticipantOrder = (participantsToRender) => { + const ids = participantsToRender.map((participant) => participant.id); + const idSet = new Set(ids); + const preserved = participantOrder.filter((id) => idSet.has(id)); + const preservedSet = new Set(preserved); + const missing = ids.filter((id) => !preservedSet.has(id)); + + missing.sort((a, b) => { + const rankDelta = getParticipantDefaultRank(a) - getParticipantDefaultRank(b); + if (rankDelta !== 0) return rankDelta; + return a.localeCompare(b); + }); + + const nextOrder = [...preserved, ...missing]; + const changed = + nextOrder.length !== participantOrder.length || + nextOrder.some((id, index) => id !== participantOrder[index]); + + if (changed) { + participantOrder = nextOrder; + saveParticipantOrder(); + } + + return participantOrder; + }; + + const sortParticipantsForDisplay = (participantsToRender) => { + const order = ensureParticipantOrder(participantsToRender); + const orderIndex = new Map(order.map((id, index) => [id, index])); + + return [...participantsToRender].sort((a, b) => { + const aIndex = orderIndex.get(a.id); + const bIndex = orderIndex.get(b.id); + return aIndex - bIndex; + }); + }; + + let draggedParticipantId = null; + let draggedPointerId = null; + let dropTargetParticipantId = null; + let dropAfterTarget = false; + let lastParticipantsToRender = []; + + const clearParticipantDragVisuals = () => { + if (!participantsPanel) return; + + participantsPanel.querySelectorAll('.participant-row').forEach((row) => { + row.classList.remove('is-dragging', 'drag-over-before', 'drag-over-after'); + }); + }; + + const moveParticipantToIndex = (participantId, targetIndex) => { + const fromIndex = participantOrder.indexOf(participantId); + if (fromIndex < 0) return false; + + const boundedIndex = Math.max(0, Math.min(targetIndex, participantOrder.length - 1)); + if (fromIndex === boundedIndex) return false; + + participantOrder.splice(fromIndex, 1); + participantOrder.splice(boundedIndex, 0, participantId); + saveParticipantOrder(); + return true; + }; + + const endParticipantDrag = () => { + draggedParticipantId = null; + draggedPointerId = null; + dropTargetParticipantId = null; + dropAfterTarget = false; + clearParticipantDragVisuals(); + }; + + const getInitialParticipantsPanelVisible = () => { + const saved = localStorage.getItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY); + if (saved === 'true' || saved === 'false') { + return saved === 'true'; + } + + return !window.matchMedia('(max-width: 720px)').matches; + }; + + let participantsPanelVisible = getInitialParticipantsPanelVisible(); + const applyParticipantsPanelVisibility = () => { + if (participantsPanel) { + participantsPanel.classList.toggle('is-hidden', !participantsPanelVisible); + } + if (participantsToggle) { + participantsToggle.textContent = participantsPanelVisible ? 'Hide Participants' : 'Show Participants'; + participantsToggle.setAttribute('aria-expanded', String(participantsPanelVisible)); + } + + localStorage.setItem(PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY, String(participantsPanelVisible)); + }; + + if (participantsToggle) { + participantsToggle.addEventListener('click', () => { + participantsPanelVisible = !participantsPanelVisible; + applyParticipantsPanelVisibility(); + }); + } + + document.addEventListener('pointerdown', (event) => { + if (!participantsPanelVisible) return; + + const target = event.target; + if (!(target instanceof Node)) return; + + const clickedInsidePanel = participantsPanel ? participantsPanel.contains(target) : false; + const clickedToggle = participantsToggle ? participantsToggle.contains(target) : false; + + if (clickedInsidePanel || clickedToggle) return; + + participantsPanelVisible = false; + applyParticipantsPanelVisibility(); + }); + + document.addEventListener('pointermove', (event) => { + if (!draggedParticipantId || event.pointerId !== draggedPointerId) return; + + event.preventDefault(); + + const pointed = document.elementFromPoint(event.clientX, event.clientY); + if (!(pointed instanceof Element) || !participantsPanel) { + dropTargetParticipantId = null; + dropAfterTarget = false; + clearParticipantDragVisuals(); + return; + } + + const targetRow = pointed.closest('.participant-row'); + clearParticipantDragVisuals(); + + const draggedRow = participantsPanel.querySelector(`[data-participant-id="${draggedParticipantId}"]`); + if (draggedRow) { + draggedRow.classList.add('is-dragging'); + } + + if (!(targetRow instanceof HTMLElement) || !participantsPanel.contains(targetRow)) { + dropTargetParticipantId = null; + dropAfterTarget = false; + return; + } + + const targetId = targetRow.dataset.participantId; + if (!targetId || targetId === draggedParticipantId) { + dropTargetParticipantId = null; + dropAfterTarget = false; + return; + } + + const rect = targetRow.getBoundingClientRect(); + const after = event.clientY > rect.top + rect.height / 2; + + dropTargetParticipantId = targetId; + dropAfterTarget = after; + targetRow.classList.add(after ? 'drag-over-after' : 'drag-over-before'); + }); + + document.addEventListener('pointerup', (event) => { + if (!draggedParticipantId || event.pointerId !== draggedPointerId) return; + + const draggedId = draggedParticipantId; + const targetId = dropTargetParticipantId; + const insertAfter = dropAfterTarget; + endParticipantDrag(); + + if (!targetId) return; + + const targetIndex = participantOrder.indexOf(targetId); + if (targetIndex < 0) return; + + let nextIndex = insertAfter ? targetIndex + 1 : targetIndex; + const currentIndex = participantOrder.indexOf(draggedId); + if (currentIndex >= 0 && currentIndex < nextIndex) { + nextIndex -= 1; + } + + if (!moveParticipantToIndex(draggedId, nextIndex)) return; + + renderParticipantControls(lastParticipantsToRender); + requestRender(); + }); + + document.addEventListener('pointercancel', () => { + if (!draggedParticipantId) return; + endParticipantDrag(); + }); + + applyParticipantsPanelVisibility(); + + const isParticipantVisible = (participantId) => participantVisibility[participantId] !== false; + const setParticipantVisibility = (participantId, visible) => { + participantVisibility[participantId] = visible; + localStorage.setItem(PARTICIPANT_VISIBILITY_STORAGE_KEY, JSON.stringify(participantVisibility)); + }; + + const getParticipantsToRender = (participants) => { + const participantsToRender = participants.filter((p) => p.id !== selfParticipant.id); + + if (participantsToRender.findIndex((p) => p.id === northMarkerId) === -1) { + participantsToRender.push({ + id: northMarkerId, + name: 'North', + color: '#000000', + position: { + lat: selfPosition.lat + 0.001, + lon: selfPosition.lon + }, + timestamp: Date.now() + }); + } + + return sortParticipantsForDisplay(participantsToRender); + }; + + const renderParticipantControls = (participantsToRender) => { + lastParticipantsToRender = participantsToRender; + if (!participantsPanel) return; + + participantsPanel.textContent = ''; + const heading = document.createElement('h3'); + heading.textContent = 'Participants'; + participantsPanel.appendChild(heading); + + for (const participant of participantsToRender) { + const row = document.createElement('div'); + row.className = 'participant-row'; + row.dataset.participantId = participant.id; + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = isParticipantVisible(participant.id); + checkbox.addEventListener('change', () => { + setParticipantVisibility(participant.id, checkbox.checked); + requestRender(); + }); + + const nameWrapper = document.createElement('div'); + nameWrapper.className = 'participant-name-badge'; + nameWrapper.style.backgroundColor = participant.color; + nameWrapper.style.color = getReadableTextColor(participant.color); + + const nameText = document.createElement('span'); + nameText.className = 'participant-name'; + nameText.textContent = participant.name; + nameWrapper.appendChild(nameText); + + const mapLink = document.createElement('a'); + mapLink.className = 'participant-map-link'; + mapLink.href = `https://www.google.com/maps?q=${participant.position.lat},${participant.position.lon}`; + mapLink.target = '_blank'; + mapLink.rel = 'noopener noreferrer'; + mapLink.textContent = '📍'; + mapLink.title = `Open ${participant.name} in Google Maps`; + mapLink.setAttribute('aria-label', `Open ${participant.name} in Google Maps`); + + row.appendChild(checkbox); + if (shouldShowMapLink(participant.id)) { + nameWrapper.appendChild(mapLink); + } + + const dragHandle = document.createElement('button'); + dragHandle.type = 'button'; + dragHandle.className = 'participant-drag-handle'; + dragHandle.textContent = '☰'; + dragHandle.title = `Drag to reorder ${participant.name}`; + dragHandle.setAttribute('aria-label', `Drag to reorder ${participant.name}`); + dragHandle.addEventListener('pointerdown', (event) => { + if (event.button !== undefined && event.button !== 0) return; + + event.preventDefault(); + draggedParticipantId = participant.id; + draggedPointerId = event.pointerId; + dropTargetParticipantId = null; + dropAfterTarget = false; + + clearParticipantDragVisuals(); + row.classList.add('is-dragging'); + }); + + row.appendChild(dragHandle); + row.appendChild(nameWrapper); + participantsPanel.appendChild(row); + } + }; + + return { + isParticipantVisible, + getParticipantsToRender, + renderParticipantControls + }; +}; diff --git a/src/participants-sync.js b/src/participants-sync.js new file mode 100644 index 0000000..3046698 --- /dev/null +++ b/src/participants-sync.js @@ -0,0 +1,59 @@ +import { ref, onValue } from "https://www.gstatic.com/firebasejs/12.10.0/firebase-database.js"; +import { decryptJson } from './crypto.js'; + +/** + * Subscribes to the participants node for a room, decrypting each record + * as needed. Calls onUpdate(participants) after every snapshot resolves. + * + * @param {object} opts + * @param {object} opts.db - Firebase Database instance + * @param {string} opts.roomId + * @param {CryptoKey|null} opts.roomKey - AES-GCM key, or null for unencrypted rooms + * @param {function} opts.onUpdate - (participants: object[]) => void + * @returns {{ participants: object[] }} Shared array reference, mutated in place on each update + */ +export function subscribeToParticipants({ db, roomId, roomKey, onUpdate }) { + const participantsRef = ref(db, `rooms/${roomId}/participants`); + const participants = []; + let snapshotVersion = 0; + + onValue(participantsRef, async (snapshot) => { + const version = ++snapshotVersion; + + const participantsData = snapshot.val(); + if (participantsData) { + const rawParticipants = Object.values(participantsData); + const decrypted = await Promise.all(rawParticipants.map(async (p) => { + if (p.encryptedState && roomKey) { + try { + return await decryptJson(roomKey, p.encryptedState); + } catch (err) { + console.error('Failed to decrypt participant state', p.id, err); + return null; + } + } + if (p.encryptedPosition && roomKey) { + try { + const position = await decryptJson(roomKey, p.encryptedPosition); + const { encryptedPosition: _stripped, ...rest } = p; + return { ...rest, position }; + } catch (err) { + console.error('Failed to decrypt legacy participant position', p.id, err); + return null; + } + } + return p; + })); + if (version !== snapshotVersion) return; + participants.length = 0; + participants.push(...decrypted.filter(Boolean)); + } else { + if (version !== snapshotVersion) return; + participants.length = 0; + } + + onUpdate(participants); + }); + + return { participants }; +} diff --git a/src/qr.js b/src/qr.js new file mode 100644 index 0000000..49b50b6 --- /dev/null +++ b/src/qr.js @@ -0,0 +1,51 @@ +import QRCode from 'https://cdn.jsdelivr.net/npm/qrcode@1.5.4/+esm'; + +export const renderInviteQrCard = async ({ + container, + title, + url, + statusText, + roomId +}) => { + if (!container) return; + + const card = document.createElement('div'); + card.className = 'room-qr-card'; + card.setAttribute('aria-label', title ? `${title} invite QR code` : 'Invite QR code'); + + if (title) { + const label = document.createElement('p'); + label.className = 'room-qr-title'; + label.textContent = title; + card.appendChild(label); + } + + const qrImage = document.createElement('img'); + qrImage.className = 'room-qr'; + qrImage.alt = title ? `${title}: scan to join room ${roomId}` : `Scan to join room ${roomId}`; + card.appendChild(qrImage); + + const status = document.createElement('p'); + status.className = 'room-link-status'; + status.textContent = statusText; + card.appendChild(status); + + container.appendChild(card); + + try { + const qrDataUrl = await QRCode.toDataURL(url, { + width: 400, + margin: 4, + errorCorrectionLevel: 'H', + color: { + dark: '#0f172a', + light: '#ffffff' + } + }); + qrImage.src = qrDataUrl; + } catch (err) { + console.error('Failed to generate invite QR code', err); + status.textContent = 'Failed to create QR'; + status.classList.add('error'); + } +}; diff --git a/src/renderer.js b/src/renderer.js new file mode 100644 index 0000000..0e5bcfe --- /dev/null +++ b/src/renderer.js @@ -0,0 +1,78 @@ +import { getShortestDelta, normalizeAngle } from './utils.js'; + +export const renderArrows = ({ + content, + arrow, + participantsToRender, + selfParticipant, + selfHeading, + isParticipantVisible, + getArrowDisplayColor +}) => { + const activeArrowIds = new Set( + participantsToRender + .filter((target) => target.id !== selfParticipant.id) + .map((target) => `arrow-${target.id}`) + ); + + for (const target of participantsToRender) { + if (target.id === selfParticipant.id) continue; + + let existingArrow = document.getElementById(`arrow-${target.id}`); + if (!existingArrow) { + const newArrow = arrow.cloneNode(true); + newArrow.id = `arrow-${target.id}`; + newArrow.style.display = 'block'; + newArrow.currentAngle = null; + + const arrowLabelAnchor = document.createElement('div'); + arrowLabelAnchor.className = 'arrow-label-anchor'; + + const arrowLabel = document.createElement('span'); + arrowLabel.className = 'arrow-label'; + arrowLabel.textContent = target.name; + arrowLabelAnchor.appendChild(arrowLabel); + newArrow.appendChild(arrowLabelAnchor); + + content.appendChild(newArrow); + existingArrow = newArrow; + } + + content.appendChild(existingArrow); + + if (!isParticipantVisible(target.id)) { + existingArrow.style.display = 'none'; + continue; + } + + existingArrow.style.display = 'block'; + + const dx = target.position.lon - selfParticipant.position.lon; + const dy = target.position.lat - selfParticipant.position.lat; + const rawAngle = Math.atan2(dx, dy) * (180 / Math.PI); + const targetAngle = normalizeAngle(rawAngle - selfHeading); + + let currentArrowAngle = existingArrow.currentAngle; + if (currentArrowAngle === null) { + currentArrowAngle = targetAngle; + } else { + currentArrowAngle += getShortestDelta(currentArrowAngle, targetAngle) * 0.1; + } + + existingArrow.currentAngle = currentArrowAngle; + existingArrow.style.transform = `rotate(${currentArrowAngle}deg)`; + existingArrow.style.color = getArrowDisplayColor(target.color); + + const arrowLabel = existingArrow.querySelector('.arrow-label'); + if (arrowLabel) { + arrowLabel.textContent = target.name; + arrowLabel.style.transform = `rotate(${-currentArrowAngle}deg)`; + } + } + + content.querySelectorAll('.arrow[id^="arrow-"]').forEach((arrowElement) => { + if (!activeArrowIds.has(arrowElement.id)) { + arrowElement.remove(); + } + }); +}; diff --git a/src/room.js b/src/room.js new file mode 100644 index 0000000..1c4cc0c --- /dev/null +++ b/src/room.js @@ -0,0 +1,18 @@ +export const ROOM_ID_LENGTH = 8; +const ROOM_KEY_PATTERN = /^[A-Za-z0-9_-]+$/; + +export const buildRoomHash = (nextRoomId, nextRoomKeyBase64 = null) => { + const keyPart = nextRoomKeyBase64 ? `.${nextRoomKeyBase64}` : ''; + return `${nextRoomId}${keyPart}`; +}; + +export const parseRoomFromHash = (hashValue) => { + const normalizedHash = (hashValue || '').startsWith('#') ? hashValue.slice(1) : (hashValue || ''); + const compactMatch = normalizedHash.match(/^([a-z0-9]{8})(?:\.([A-Za-z0-9_-]+))?$/); + if (!compactMatch) return null; + + const roomValue = compactMatch[1]; + const keyValue = compactMatch[2] || null; + if (keyValue && !ROOM_KEY_PATTERN.test(keyValue)) return null; + return { roomId: roomValue, roomKeyBase64: keyValue }; +}; diff --git a/src/self-profile.js b/src/self-profile.js new file mode 100644 index 0000000..153dfcf --- /dev/null +++ b/src/self-profile.js @@ -0,0 +1,109 @@ +import { normalizeName, normalizeColor, createRandomUserName, createRandomColor } from './utils.js'; + +const NAME_STORAGE_KEY = 'north-star-self-name-v2'; +const COLOR_STORAGE_KEY = 'north-star-self-color'; + +const resolveInitialSelfName = async () => { + const savedName = normalizeName(localStorage.getItem(NAME_STORAGE_KEY)); + if (savedName) return savedName; + + const generatedName = await createRandomUserName(); + localStorage.setItem(NAME_STORAGE_KEY, generatedName); + return generatedName; +}; + +const resolveInitialSelfColor = () => { + const savedColor = normalizeColor(localStorage.getItem(COLOR_STORAGE_KEY)); + if (savedColor) return savedColor; + + const generatedColor = createRandomColor(); + localStorage.setItem(COLOR_STORAGE_KEY, generatedColor); + return generatedColor; +}; + +/** + * Creates the selfParticipant object, populates form inputs, and wires + * name/color change handlers. + * + * @param {object} opts + * @param {HTMLElement} opts.nameForm + * @param {HTMLInputElement} opts.selfNameInput + * @param {HTMLInputElement} opts.selfColorInput + * @param {string} opts.uid - Firebase UID used as participant ID + * @param {object} opts.selfPosition - Shared { lat, lon } reference (mutated externally) + * @param {function} opts.syncSelf - async () => void, called after name/color changes + * @param {function} opts.requestRender - triggers a render frame + * @returns {{ selfParticipant: object }} + */ +export async function initSelfProfile({ + nameForm, + selfNameInput, + selfColorInput, + uid, + selfPosition, + syncSelf, + requestRender +}) { + const initialSelfName = await resolveInitialSelfName(); + const initialSelfColor = resolveInitialSelfColor(); + + const selfParticipant = { + id: uid, + name: initialSelfName, + color: initialSelfColor, + position: selfPosition, + timestamp: Date.now() + }; + + if (selfNameInput) selfNameInput.value = selfParticipant.name; + if (selfColorInput) selfColorInput.value = selfParticipant.color; + + const applySelfName = async (rawName) => { + const nextName = normalizeName(rawName) || await createRandomUserName(); + if (nextName === selfParticipant.name) { + if (selfNameInput) selfNameInput.value = nextName; + return; + } + selfParticipant.name = nextName; + localStorage.setItem(NAME_STORAGE_KEY, nextName); + if (selfNameInput) selfNameInput.value = nextName; + await syncSelf(); + requestRender(); + }; + + const applySelfColor = async (rawColor) => { + const nextColor = normalizeColor(rawColor) || createRandomColor(); + if (nextColor === selfParticipant.color) { + if (selfColorInput) selfColorInput.value = nextColor; + return; + } + selfParticipant.color = nextColor; + localStorage.setItem(COLOR_STORAGE_KEY, nextColor); + if (selfColorInput) selfColorInput.value = nextColor; + await syncSelf(); + requestRender(); + }; + + if (nameForm) { + nameForm.addEventListener('submit', async (event) => { + event.preventDefault(); + await applySelfName(selfNameInput ? selfNameInput.value : selfParticipant.name); + await applySelfColor(selfColorInput ? selfColorInput.value : selfParticipant.color); + if (selfNameInput) selfNameInput.blur(); + }); + } + + if (selfNameInput) { + selfNameInput.addEventListener('change', async () => { + await applySelfName(selfNameInput.value); + }); + } + + if (selfColorInput) { + selfColorInput.addEventListener('change', async () => { + await applySelfColor(selfColorInput.value); + }); + } + + return { selfParticipant }; +} diff --git a/src/session.js b/src/session.js new file mode 100644 index 0000000..4892ff7 --- /dev/null +++ b/src/session.js @@ -0,0 +1,69 @@ +import { generateRoomKey, exportRoomKey, importRoomKey } from './crypto.js'; +import { buildRoomHash, parseRoomFromHash, ROOM_ID_LENGTH } from './room.js'; +import { renderInviteQrCard } from './qr.js'; + +/** + * Parses an existing room from the URL hash, or generates a new one. + * Sets room UI elements and renders invite QR cards. + * Returns { roomId, roomKey, roomKeyBase64 }. + */ +export async function initSession({ + roomCurrent, + createRoomLink, + inviteRoomQrList, + isLocalCommit, + BASE_ROOM_URL, + GITLAB_PAGES_BASE_URL +}) { + let roomKey = null; + let roomKeyBase64 = null; + let roomId; + + const parsedRoom = parseRoomFromHash(location.hash); + if (parsedRoom) { + roomId = parsedRoom.roomId; + if (parsedRoom.roomKeyBase64) { + try { + roomKey = await importRoomKey(parsedRoom.roomKeyBase64); + roomKeyBase64 = parsedRoom.roomKeyBase64; + } catch (err) { + console.error('Failed to import room key from URL — running without encryption', err); + } + } + } else { + roomId = crypto.randomUUID().replace(/-/g, '').slice(0, ROOM_ID_LENGTH); + roomKey = await generateRoomKey(); + roomKeyBase64 = await exportRoomKey(roomKey); + location.hash = buildRoomHash(roomId, roomKeyBase64); + } + + if (roomCurrent) roomCurrent.textContent = `You are currently in room ${roomId}`; + if (createRoomLink) createRoomLink.href = BASE_ROOM_URL; + + if (inviteRoomQrList) { + const inviteRoomUrl = `${BASE_ROOM_URL}#${buildRoomHash(roomId, roomKeyBase64)}`; + inviteRoomQrList.textContent = ''; + const isLocalRun = isLocalCommit || ['localhost', '127.0.0.1', '::1'].includes(location.hostname); + const roomHash = buildRoomHash(roomId, roomKeyBase64); + + await renderInviteQrCard({ + container: inviteRoomQrList, + title: isLocalRun ? 'This Device URL' : '', + url: inviteRoomUrl, + statusText: 'Scan to join this room', + roomId + }); + + if (isLocalRun) { + await renderInviteQrCard({ + container: inviteRoomQrList, + title: 'GitLab Pages URL', + url: `${GITLAB_PAGES_BASE_URL}#${roomHash}`, + statusText: 'Scan to join on hosted app', + roomId + }); + } + } + + return { roomId, roomKey, roomKeyBase64 }; +} diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..9425f6e --- /dev/null +++ b/src/utils.js @@ -0,0 +1,71 @@ +export const getShortestDelta = (fromAngle, toAngle) => ((toAngle - fromAngle + 540) % 360) - 180; + +export const normalizeAngle = (angle) => ((angle % 360) + 360) % 360; + +export const normalizeSignedAngle = (angle) => { + const normalized = normalizeAngle(angle); + return normalized > 180 ? normalized - 360 : normalized; +}; + +export const normalizeName = (value) => (value || '').trim().slice(0, 32); + +export const normalizeColor = (value) => { + const candidate = (value || '').trim().toLowerCase(); + return /^#[0-9a-f]{6}$/.test(candidate) ? candidate : ''; +}; + +export const getReadableTextColor = (hexColor) => { + const normalized = normalizeColor(hexColor); + if (!normalized) return '#0f172a'; + + const r = parseInt(normalized.slice(1, 3), 16); + const g = parseInt(normalized.slice(3, 5), 16); + const b = parseInt(normalized.slice(5, 7), 16); + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + + return luminance > 145 ? '#0f172a' : '#f8fafc'; +}; + +export const getArrowDisplayColor = (hexColor) => { + if (!document.body.classList.contains('dark')) return hexColor; + const normalized = normalizeColor(hexColor); + if (!normalized) return '#ffffff'; + + const r = parseInt(normalized.slice(1, 3), 16) / 255; + const g = parseInt(normalized.slice(3, 5), 16) / 255; + const b = parseInt(normalized.slice(5, 7), 16) / 255; + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return luminance < 0.2 ? '#ffffff' : hexColor; +}; + +export const getDeviceHint = async () => { + if (navigator.userAgentData) { + try { + const data = await navigator.userAgentData.getHighEntropyValues(['model']); + if (data.model) return data.model; + } catch { + // Fall through to UA parsing. + } + } + + const ua = navigator.userAgent; + if (/iPhone/.test(ua)) return 'iPhone'; + if (/iPad/.test(ua)) return 'iPad'; + + const androidModel = ua.match(/;\s*([^;]+?)\s+Build\//); + if (androidModel) return androidModel[1]; + + if (/Android/.test(ua)) return 'Android'; + if (/Mac/.test(ua)) return 'Mac'; + if (/Windows/.test(ua)) return 'PC'; + if (/Linux/.test(ua)) return 'Linux'; + return null; +}; + +export const createRandomUserName = async () => { + const hint = await getDeviceHint(); + const suffix = Math.floor(1000 + Math.random() * 9000); + return hint ? `${hint} ${suffix}` : `User ${suffix}`; +}; + +export const createRandomColor = () => `#${Math.floor(Math.random() * 0x1000000).toString(16).padStart(6, '0')}`; diff --git a/src/wakelock.js b/src/wakelock.js new file mode 100644 index 0000000..07bdeb1 --- /dev/null +++ b/src/wakelock.js @@ -0,0 +1,24 @@ +export const createWakeLockManager = () => { + let wakeLockSentinel = null; + const hasWakeLockSupport = typeof navigator !== 'undefined' && 'wakeLock' in navigator; + + const requestWakeLock = async () => { + if (!hasWakeLockSupport) return; + if (document.visibilityState !== 'visible') return; + if (wakeLockSentinel) return; + + try { + wakeLockSentinel = await navigator.wakeLock.request('screen'); + wakeLockSentinel.addEventListener('release', () => { + wakeLockSentinel = null; + if (document.visibilityState === 'visible') { + void requestWakeLock(); + } + }); + } catch (err) { + console.debug('Wake lock request failed', err); + } + }; + + return { requestWakeLock }; +};