north-star / commit
turned on ts strict mode
package-lock.json | 235 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 5 +- src/app.ts | 15 +-- src/compass.ts | 56 +++++++---- src/crypto.ts | 8 +- src/darkmode.ts | 4 +- src/friends-panel.ts | 61 +++++++----- src/friends-sync.ts | 28 ++++-- src/participants-panel.ts | 75 +++++++++------ src/qr.ts | 6 ++ src/renderer.ts | 24 ++++- src/room.ts | 4 +- src/self-profile.ts | 14 ++- src/session.ts | 13 ++- src/utils.ts | 30 ++++-- src/wakelock.ts | 4 +- tsconfig.json | 2 +- vite.config.js | 6 ++ 18 files changed, 470 insertions(+), 120 deletions(-) diff --git a/package-lock.json b/package-lock.json index 07c7959..4b4f22e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,44 @@ { - "name": "north-star", + "name": "friend-compass", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "north-star", + "name": "friend-compass", "version": "0.1.0", "dependencies": { "firebase": "^12.0.0", "qrcode": "^1.5.4" }, "devDependencies": { + "@types/qrcode": "^1.5.6", "typescript": "^5.8.2", - "vite": "^5.4.19" + "vite": "^5.4.19", + "vite-plugin-checker": "^0.12.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1370,6 +1395,15 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1400,6 +1434,21 @@ "node": ">=6" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -1504,6 +1553,23 @@ "node": ">=0.8.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -1591,6 +1657,12 @@ "node": ">=8" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, "node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -1630,6 +1702,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -1671,12 +1759,36 @@ "node": ">=8" } }, + "node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -1813,6 +1925,19 @@ "node": ">=6" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1927,6 +2052,28 @@ "node": ">=8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1950,6 +2097,18 @@ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/vite": { "version": "5.4.21", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", @@ -2009,6 +2168,76 @@ } } }, + "node_modules/vite-plugin-checker": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.12.0.tgz", + "integrity": "sha512-CmdZdDOGss7kdQwv73UyVgLPv0FVYe5czAgnmRX2oKljgEvSrODGuClaV3PDR2+3ou7N/OKGauDDBjy2MB07Rg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "chokidar": "^4.0.3", + "npm-run-path": "^6.0.0", + "picocolors": "^1.1.1", + "picomatch": "^4.0.3", + "tiny-invariant": "^1.3.3", + "tinyglobby": "^0.2.15", + "vscode-uri": "^3.1.0" + }, + "engines": { + "node": ">=16.11" + }, + "peerDependencies": { + "@biomejs/biome": ">=1.7", + "eslint": ">=9.39.1", + "meow": "^13.2.0", + "optionator": "^0.9.4", + "oxlint": ">=1", + "stylelint": ">=16", + "typescript": "*", + "vite": ">=5.4.21", + "vls": "*", + "vti": "*", + "vue-tsc": "~2.2.10 || ^3.0.0" + }, + "peerDependenciesMeta": { + "@biomejs/biome": { + "optional": true + }, + "eslint": { + "optional": true + }, + "meow": { + "optional": true + }, + "optionator": { + "optional": true + }, + "oxlint": { + "optional": true + }, + "stylelint": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vls": { + "optional": true + }, + "vti": { + "optional": true + }, + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true + }, "node_modules/web-vitals": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", diff --git a/package.json b/package.json index ac812a2..f35100d 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "typecheck": "tsc --noEmit", "build": "vite build", "preview": "vite preview", "gen-commit-ts": "echo \"export const GIT_COMMIT='${CI_COMMIT_SHORT_SHA}'; export const IS_LOCAL_BUILD=false;\" > ./src/commit.ts" @@ -14,7 +15,9 @@ "qrcode": "^1.5.4" }, "devDependencies": { + "@types/qrcode": "^1.5.6", "typescript": "^5.8.2", - "vite": "^5.4.19" + "vite": "^5.4.19", + "vite-plugin-checker": "^0.12.0" } } \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 9621b78..c06d70b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,14 +16,14 @@ import { PUBLIC_SETTINGS } from './public-settings'; const debugCommit = document.getElementById('debug-commit'); const nameForm = document.getElementById('name-form'); -const selfNameInput = document.getElementById('self-name-input'); -const selfColorInput = document.getElementById('self-color-input'); +const selfNameInput = document.getElementById('self-name-input') as HTMLInputElement | null; +const selfColorInput = document.getElementById('self-color-input') as HTMLInputElement | null; const roomCurrent = document.getElementById('room-current'); const inviteRoomQrList = document.getElementById('invite-room-qr-list'); const calibrateHeadingLink = document.getElementById('calibrate-heading-link'); const darkModeToggle = document.getElementById('dark-mode-toggle'); const cleanModeToggle = document.getElementById('clean-mode-toggle'); -const createRoomLink = document.getElementById('create-room-link'); +const createRoomLink = document.getElementById('create-room-link') as HTMLAnchorElement | null; const roomLinks = document.getElementById('room-links'); const friendsPanel = document.getElementById('friends-panel'); const friendsToggle = document.getElementById('friends-toggle'); @@ -123,7 +123,7 @@ if (debugCommit instanceof HTMLAnchorElement) { // selfFriend is assigned below by initSelfProfile; syncSelfFriend is // only ever called after that point (from event handlers / intervals). -let selfFriend; +let selfFriend: any; let lastSyncedPosition: { lat: number; lon: number } | null = null; let lastLocationSyncAt = 0; let geolocationWatchId: number | null = null; @@ -225,7 +225,7 @@ const requestRender = () => { const compassController = initCompassController({ calibrateHeadingLink, initialHeadingCorrectionDeg: headingCorrectionDeg, - onHeadingCorrectionDegChange: (next) => { headingCorrectionDeg = next; }, + onHeadingCorrectionDegChange: (next: number) => { headingCorrectionDeg = next; }, requestRender }); @@ -283,8 +283,8 @@ const startSession = async () => { db, roomId, roomKey, - onUpdate: (updated) => { - const remoteFriendCount = updated.filter((friend) => friend?.id && friend.id !== selfFriend.id).length; + onUpdate: (updated: any[]) => { + const remoteFriendCount = updated.filter((friend: any) => friend?.id && friend.id !== selfFriend.id).length; if (remoteFriendCount > previousRemoteFriendCount && inviteRoomQrList instanceof HTMLElement) { bumpQrAutoCollapseCountdown(inviteRoomQrList); } @@ -377,6 +377,7 @@ document.addEventListener('visibilitychange', () => { const render = () => { if (!sessionStarted) return; + if (!content || !arrow) return; const now = Date.now(); const friendsToRender = friendsController.getFriendsToRender(friends); diff --git a/src/compass.ts b/src/compass.ts index c913085..7dcfd4b 100644 --- a/src/compass.ts +++ b/src/compass.ts @@ -1,16 +1,25 @@ import { normalizeAngle, normalizeSignedAngle } from './utils'; import { PUBLIC_SETTINGS } from './public-settings'; +type CompassControllerOptions = { + calibrateHeadingLink: HTMLElement | null; + initialHeadingCorrectionDeg: number; + onHeadingCorrectionDegChange: (next: number) => void; + requestRender: () => void; +}; + +type CompassPermissionState = 'granted' | 'denied'; + export const initCompassController = ({ calibrateHeadingLink, initialHeadingCorrectionDeg, onHeadingCorrectionDegChange, requestRender -}) => { +}: CompassControllerOptions) => { const HEADING_CORRECTION_STORAGE_KEY = 'friend-compass-heading-correction-deg'; const deviceOrientationCtor = typeof DeviceOrientationEvent !== 'undefined' - ? (DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<'granted' | 'denied'> }) + ? (DeviceOrientationEvent as unknown as { requestPermission?: () => Promise<CompassPermissionState> }) : null; const requiresExplicitCompassPermission = @@ -20,7 +29,7 @@ export const initCompassController = ({ let headingCorrectionDeg = initialHeadingCorrectionDeg; let selfHeading = 0; - let lastRawHeading = null; + let lastRawHeading: number | null = null; let compassEnabled = !requiresExplicitCompassPermission; let hasSeenOrientationEvent = false; @@ -28,8 +37,8 @@ export const initCompassController = ({ let compassButtonStatusLabel = ''; let compassButtonStatusTitle = ''; let compassButtonStatusError = false; - let compassStatusTimer = null; - let compassProbeTimer = null; + let compassStatusTimer: number | null = null; + let compassProbeTimer: number | null = null; let hasAbsoluteOrientation = false; const getHeadingUnavailableMessage = () => { @@ -109,7 +118,17 @@ export const initCompassController = ({ compassProbeTimer = null; }; - const showCompassButtonStatus = ({ label, title = '', isError = false, durationMs = 0 }) => { + const showCompassButtonStatus = ({ + label, + title = '', + isError = false, + durationMs = 0 + }: { + label: string; + title?: string; + isError?: boolean; + durationMs?: number; + }) => { if (compassStatusTimer) { clearTimeout(compassStatusTimer); compassStatusTimer = null; @@ -120,7 +139,7 @@ export const initCompassController = ({ compassButtonStatusError = isError; if (durationMs > 0) { - compassStatusTimer = setTimeout(() => { + compassStatusTimer = window.setTimeout(() => { clearCompassButtonStatus(); updateCompassButtonState(); }, durationMs); @@ -137,11 +156,11 @@ export const initCompassController = ({ calibrateHeadingLink.classList.add('compass-required'); calibrateHeadingLink.classList.remove('compass-error'); calibrateHeadingLink.title = getHeadingUnavailableMessage(); - calibrateHeadingLink.disabled = true; + (calibrateHeadingLink as HTMLButtonElement).disabled = true; return; } - calibrateHeadingLink.disabled = false; + (calibrateHeadingLink as HTMLButtonElement).disabled = false; calibrateHeadingLink.textContent = compassButtonStatusLabel || (compassEnabled ? 'Calibrate North' : 'Enable Compass'); calibrateHeadingLink.title = compassButtonStatusTitle; calibrateHeadingLink.classList.toggle('compass-required', !compassEnabled); @@ -155,7 +174,7 @@ export const initCompassController = ({ if (!compassEnabled) return; if (hasSeenOrientationEvent || hasSeenHeadingValue) return; - compassProbeTimer = setTimeout(() => { + compassProbeTimer = window.setTimeout(() => { if (hasSeenOrientationEvent || hasSeenHeadingValue) return; const status = getHeadingUnavailableStatus(); @@ -185,15 +204,16 @@ export const initCompassController = ({ if (window.screen?.orientation && typeof window.screen.orientation.angle === 'number') { return window.screen.orientation.angle; } - if (typeof window.orientation === 'number') { - return window.orientation; + if (typeof (window as any).orientation === 'number') { + return (window as any).orientation as number; } return 0; }; - const getHeadingFromEvent = (orientationEvent) => { - if (typeof orientationEvent.webkitCompassHeading === 'number') { - return normalizeAngle(orientationEvent.webkitCompassHeading); + const getHeadingFromEvent = (orientationEvent: DeviceOrientationEvent) => { + const webkitEvent = orientationEvent as DeviceOrientationEvent & { webkitCompassHeading?: number }; + if (typeof webkitEvent.webkitCompassHeading === 'number') { + return normalizeAngle(webkitEvent.webkitCompassHeading); } if (orientationEvent.alpha === null) { @@ -208,7 +228,7 @@ export const initCompassController = ({ scheduleCompassProbe(); if (calibrateHeadingLink) { - calibrateHeadingLink.addEventListener('click', async (event) => { + calibrateHeadingLink.addEventListener('click', async (event: Event) => { event.preventDefault(); if (!compassEnabled) { @@ -259,7 +279,7 @@ export const initCompassController = ({ }); } - window.addEventListener('deviceorientationabsolute', (event) => { + window.addEventListener('deviceorientationabsolute', (event: DeviceOrientationEvent) => { clearCompassProbeTimer(); hasSeenOrientationEvent = true; const heading = getHeadingFromEvent(event); @@ -282,7 +302,7 @@ export const initCompassController = ({ requestRender(); }); - window.addEventListener('deviceorientation', (event) => { + window.addEventListener('deviceorientation', (event: DeviceOrientationEvent) => { if (hasAbsoluteOrientation) return; clearCompassProbeTimer(); diff --git a/src/crypto.ts b/src/crypto.ts index 35dc69d..c110f6c 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -4,20 +4,20 @@ const IV_BYTES = 12; export const generateRoomKey = () => crypto.subtle.generateKey(AES_PARAMS, true, ['encrypt', 'decrypt']); -export const exportRoomKey = async (key) => { +export const exportRoomKey = async (key: CryptoKey) => { 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) => { +export const importRoomKey = async (base64url: string) => { 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) => { +export const encryptJson = async (key: CryptoKey, value: unknown) => { 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); @@ -26,7 +26,7 @@ export const encryptJson = async (key, value) => { return `${ivB64}:${ctB64}`; }; -export const decryptJson = async (key, encryptedValue) => { +export const decryptJson = async (key: CryptoKey, encryptedValue: string) => { 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)); diff --git a/src/darkmode.ts b/src/darkmode.ts index a3e4952..4956c8c 100644 --- a/src/darkmode.ts +++ b/src/darkmode.ts @@ -1,7 +1,7 @@ export const DARK_MODE_STORAGE_KEY = 'friend-compass-dark-mode'; -export const initDarkMode = (toggleButton) => { - const applyDarkMode = (dark) => { +export const initDarkMode = (toggleButton: HTMLElement | null) => { + const applyDarkMode = (dark: boolean) => { document.body.classList.toggle('dark', dark); if (toggleButton) toggleButton.textContent = dark ? '☀️' : '🌙'; localStorage.setItem(DARK_MODE_STORAGE_KEY, dark ? '1' : '0'); diff --git a/src/friends-panel.ts b/src/friends-panel.ts index 0f72d66..0a13644 100644 --- a/src/friends-panel.ts +++ b/src/friends-panel.ts @@ -2,6 +2,15 @@ import { getFriendPresenceState, PRESENCE_EMOJI } from './utils'; import type { PresenceThresholds } from './utils'; import { PUBLIC_SETTINGS } from './public-settings'; +type FriendModel = { + id: string; + name: string; + color: string; + position: { lat: number; lon: number }; + timestamp?: number; + lastSeen?: number; +}; + export const createFriendsPanelController = ({ friendsPanel, friendsToggle, @@ -32,13 +41,13 @@ export const createFriendsPanelController = ({ const friendVisibility = (() => { try { const raw = localStorage.getItem(PARTICIPANT_VISIBILITY_STORAGE_KEY); - return raw ? JSON.parse(raw) : {}; + return raw ? JSON.parse(raw) as Record<string, boolean> : {}; } catch { - return {}; + return {} as Record<string, boolean>; } })(); - let friendOrder = (() => { + let friendOrder: string[] = (() => { try { const raw = localStorage.getItem(PARTICIPANT_ORDER_STORAGE_KEY); const parsed = raw ? JSON.parse(raw) : []; @@ -52,13 +61,13 @@ export const createFriendsPanelController = ({ localStorage.setItem(PARTICIPANT_ORDER_STORAGE_KEY, JSON.stringify(friendOrder)); }; - const getFriendDefaultRank = (friendId) => { + const getFriendDefaultRank = (friendId: string) => { if (friendId === northMarkerId) return 0; if (guardianAngelEnabled && friendId === guardianAngelId) return 1; return 2; }; - const shouldShowMapLink = (friendId) => { + const shouldShowMapLink = (friendId: string) => { return friendId !== northMarkerId && (!guardianAngelEnabled || friendId !== guardianAngelId); }; @@ -77,14 +86,14 @@ export const createFriendsPanelController = ({ }; }; - const ensureFriendOrder = (friendsToRender) => { - const ids = friendsToRender.map((friend) => friend.id); + const ensureFriendOrder = (friendsToRender: FriendModel[]) => { + const ids = friendsToRender.map((friend: FriendModel) => friend.id); const idSet = new Set(ids); - const preserved = friendOrder.filter((id) => idSet.has(id)); + const preserved = friendOrder.filter((id: string) => idSet.has(id)); const preservedSet = new Set(preserved); - const missing = ids.filter((id) => !preservedSet.has(id)); + const missing = ids.filter((id: string) => !preservedSet.has(id)); - missing.sort((a, b) => { + missing.sort((a: string, b: string) => { const rankDelta = getFriendDefaultRank(a) - getFriendDefaultRank(b); if (rankDelta !== 0) return rankDelta; return a.localeCompare(b); @@ -93,7 +102,7 @@ export const createFriendsPanelController = ({ const nextOrder = [...preserved, ...missing]; const changed = nextOrder.length !== friendOrder.length || - nextOrder.some((id, index) => id !== friendOrder[index]); + nextOrder.some((id: string, index: number) => id !== friendOrder[index]); if (changed) { friendOrder = nextOrder; @@ -103,22 +112,22 @@ export const createFriendsPanelController = ({ return friendOrder; }; - const sortFriendsForDisplay = (friendsToRender) => { + const sortFriendsForDisplay = (friendsToRender: FriendModel[]) => { const order = ensureFriendOrder(friendsToRender); - const orderIndex = new Map(order.map((id, index) => [id, index])); + const orderIndex = new Map<string, number>(order.map((id: string, index: number) => [id, index])); return [...friendsToRender].sort((a, b) => { const aIndex = orderIndex.get(a.id); const bIndex = orderIndex.get(b.id); - return aIndex - bIndex; + return (aIndex ?? Number.MAX_SAFE_INTEGER) - (bIndex ?? Number.MAX_SAFE_INTEGER); }); }; - let draggedFriendId = null; - let draggedPointerId = null; - let dropTargetFriendId = null; + let draggedFriendId: string | null = null; + let draggedPointerId: number | null = null; + let dropTargetFriendId: string | null = null; let dropAfterTarget = false; - let lastFriendsToRender = []; + let lastFriendsToRender: FriendModel[] = []; const clearFriendDragVisuals = () => { if (!friendsPanel) return; @@ -128,7 +137,7 @@ export const createFriendsPanelController = ({ }); }; - const moveFriendToIndex = (friendId, targetIndex) => { + const moveFriendToIndex = (friendId: string, targetIndex: number) => { const fromIndex = friendOrder.indexOf(friendId); if (fromIndex < 0) return false; @@ -271,14 +280,14 @@ export const createFriendsPanelController = ({ applyFriendsPanelVisibility(); - const isFriendVisible = (friendId) => friendVisibility[friendId] !== false; - const setFriendVisibility = (friendId, visible) => { + const isFriendVisible = (friendId: string) => friendVisibility[friendId] !== false; + const setFriendVisibility = (friendId: string, visible: boolean) => { friendVisibility[friendId] = visible; localStorage.setItem(PARTICIPANT_VISIBILITY_STORAGE_KEY, JSON.stringify(friendVisibility)); }; - const getFriendsToRender = (friends) => { - const friendsToRender = friends.filter((p) => p.id !== selfFriend.id); + const getFriendsToRender = (friends: FriendModel[]) => { + const friendsToRender = friends.filter((p: FriendModel) => p.id !== selfFriend.id); if (friendsToRender.findIndex((p) => p.id === northMarkerId) === -1) { friendsToRender.push({ @@ -307,7 +316,7 @@ export const createFriendsPanelController = ({ return sortFriendsForDisplay(friendsToRender); }; - const renderFriendControls = (friendsToRender) => { + const renderFriendControls = (friendsToRender: FriendModel[]) => { const orderedFriends = sortFriendsForDisplay(friendsToRender); lastFriendsToRender = orderedFriends; if (!friendsPanel) return; @@ -376,7 +385,7 @@ export const createFriendsPanelController = ({ dragHandle.textContent = '☰'; dragHandle.title = `Drag to reorder ${friend.name}`; dragHandle.setAttribute('aria-label', `Drag to reorder ${friend.name}`); - dragHandle.addEventListener('pointerdown', (event) => { + dragHandle.addEventListener('pointerdown', (event: PointerEvent) => { if (event.button !== undefined && event.button !== 0) return; event.preventDefault(); @@ -403,7 +412,7 @@ export const createFriendsPanelController = ({ if (!friendsPanel) return; friendsPanel.querySelectorAll<HTMLElement>('.friend-presence-status[data-friend-id]').forEach((el) => { const friendId = el.dataset.friendId; - const friend = lastFriendsToRender.find((f) => f.id === friendId); + const friend = lastFriendsToRender.find((f: FriendModel) => f.id === friendId); if (!friend) return; const state = getFriendPresenceState(friend, now, presenceThresholds); const emoji = PRESENCE_EMOJI[state] ?? '🟡'; diff --git a/src/friends-sync.ts b/src/friends-sync.ts index d180d35..0a69bd2 100644 --- a/src/friends-sync.ts +++ b/src/friends-sync.ts @@ -1,8 +1,14 @@ import { ref, onValue } from 'firebase/database'; +import type { Database } from 'firebase/database'; import { decryptJson } from './crypto'; type FriendRecord = { id?: string; + name?: string; + color?: string; + position?: { lat: number; lon: number }; + timestamp?: number; + lastSeen?: number; encryptedState?: string; encryptedPosition?: string; [key: string]: unknown; @@ -19,9 +25,19 @@ type FriendRecord = { * @param {function} opts.onUpdate - (friends: object[]) => void * @returns {{ friends: object[] }} Shared array reference, mutated in place on each update */ -export function subscribeToFriends({ db, roomId, roomKey, onUpdate }) { +export function subscribeToFriends({ + db, + roomId, + roomKey, + onUpdate +}: { + db: Database; + roomId: string; + roomKey: CryptoKey | null; + onUpdate: (friends: FriendRecord[]) => void; +}) { const friendsRef = ref(db, `rooms/${roomId}/friends`); - const friends = []; + const friends: FriendRecord[] = []; let snapshotVersion = 0; onValue(friendsRef, async (snapshot) => { @@ -30,10 +46,10 @@ export function subscribeToFriends({ db, roomId, roomKey, onUpdate }) { const friendsData = snapshot.val(); if (friendsData) { const rawFriends = Object.values(friendsData as Record<string, FriendRecord>); - const decrypted = await Promise.all(rawFriends.map(async (p) => { + const decrypted = await Promise.all(rawFriends.map(async (p): Promise<FriendRecord | null> => { if (p.encryptedState && roomKey) { try { - const decryptedState = await decryptJson(roomKey, p.encryptedState); + const decryptedState = await decryptJson(roomKey, p.encryptedState) as Record<string, unknown>; return { ...decryptedState, lastSeen: p.lastSeen }; } catch (err) { console.error('Failed to decrypt friend state', p.id, err); @@ -42,7 +58,7 @@ export function subscribeToFriends({ db, roomId, roomKey, onUpdate }) { } if (p.encryptedPosition && roomKey) { try { - const position = await decryptJson(roomKey, p.encryptedPosition); + const position = await decryptJson(roomKey, p.encryptedPosition) as { lat: number; lon: number }; const { encryptedPosition: _stripped, ...rest } = p; return { ...rest, position }; } catch (err) { @@ -54,7 +70,7 @@ export function subscribeToFriends({ db, roomId, roomKey, onUpdate }) { })); if (version !== snapshotVersion) return; friends.length = 0; - friends.push(...decrypted.filter(Boolean)); + friends.push(...decrypted.filter((item): item is FriendRecord => item !== null)); } else { if (version !== snapshotVersion) return; friends.length = 0; diff --git a/src/participants-panel.ts b/src/participants-panel.ts index b83a0cc..34da9cf 100644 --- a/src/participants-panel.ts +++ b/src/participants-panel.ts @@ -1,3 +1,12 @@ +type ParticipantModel = { + id: string; + name: string; + color: string; + position: { lat: number; lon: number }; + timestamp?: number; + lastSeen?: number; +}; + export const createParticipantsPanelController = ({ participantsPanel, participantsToggle, @@ -6,6 +15,14 @@ export const createParticipantsPanelController = ({ northMarkerId, selfParticipant, selfPosition +}: { + participantsPanel: HTMLElement | null; + participantsToggle: HTMLElement | null; + requestRender: () => void; + getReadableTextColor: (hexColor: string) => string; + northMarkerId: string; + selfParticipant: { id: string }; + selfPosition: { lat: number; lon: number }; }) => { const PARTICIPANT_VISIBILITY_STORAGE_KEY = 'friend-compass-friend-visibility'; const PARTICIPANTS_PANEL_VISIBLE_STORAGE_KEY = 'friend-compass-friends-panel-visible'; @@ -14,17 +31,17 @@ export const createParticipantsPanelController = ({ const participantVisibility = (() => { try { const raw = localStorage.getItem(PARTICIPANT_VISIBILITY_STORAGE_KEY); - return raw ? JSON.parse(raw) : {}; + return raw ? JSON.parse(raw) as Record<string, boolean> : {} as Record<string, boolean>; } catch { - return {}; + return {} as Record<string, boolean>; } })(); - let participantOrder = (() => { + let participantOrder: string[] = (() => { 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') : []; + return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []; } catch { return []; } @@ -34,23 +51,23 @@ export const createParticipantsPanelController = ({ localStorage.setItem(PARTICIPANT_ORDER_STORAGE_KEY, JSON.stringify(participantOrder)); }; - const getParticipantDefaultRank = (participantId) => { + const getParticipantDefaultRank = (participantId: string) => { if (participantId === northMarkerId) return 0; return 1; }; - const shouldShowMapLink = (participantId) => { + const shouldShowMapLink = (participantId: string) => { return participantId !== northMarkerId; }; - const ensureParticipantOrder = (participantsToRender) => { - const ids = participantsToRender.map((participant) => participant.id); + const ensureParticipantOrder = (participantsToRender: ParticipantModel[]) => { + const ids = participantsToRender.map((participant: ParticipantModel) => participant.id); const idSet = new Set(ids); - const preserved = participantOrder.filter((id) => idSet.has(id)); + const preserved = participantOrder.filter((id: string) => idSet.has(id)); const preservedSet = new Set(preserved); - const missing = ids.filter((id) => !preservedSet.has(id)); + const missing = ids.filter((id: string) => !preservedSet.has(id)); - missing.sort((a, b) => { + missing.sort((a: string, b: string) => { const rankDelta = getParticipantDefaultRank(a) - getParticipantDefaultRank(b); if (rankDelta !== 0) return rankDelta; return a.localeCompare(b); @@ -59,7 +76,7 @@ export const createParticipantsPanelController = ({ const nextOrder = [...preserved, ...missing]; const changed = nextOrder.length !== participantOrder.length || - nextOrder.some((id, index) => id !== participantOrder[index]); + nextOrder.some((id: string, index: number) => id !== participantOrder[index]); if (changed) { participantOrder = nextOrder; @@ -69,32 +86,32 @@ export const createParticipantsPanelController = ({ return participantOrder; }; - const sortParticipantsForDisplay = (participantsToRender) => { + const sortParticipantsForDisplay = (participantsToRender: ParticipantModel[]) => { const order = ensureParticipantOrder(participantsToRender); - const orderIndex = new Map(order.map((id, index) => [id, index])); + const orderIndex = new Map<string, number>(order.map((id: string, index: number) => [id, index])); return [...participantsToRender].sort((a, b) => { const aIndex = orderIndex.get(a.id); const bIndex = orderIndex.get(b.id); - return aIndex - bIndex; + return (aIndex ?? Number.MAX_SAFE_INTEGER) - (bIndex ?? Number.MAX_SAFE_INTEGER); }); }; - let draggedParticipantId = null; - let draggedPointerId = null; - let dropTargetParticipantId = null; + let draggedParticipantId: string | null = null; + let draggedPointerId: number | null = null; + let dropTargetParticipantId: string | null = null; let dropAfterTarget = false; - let lastParticipantsToRender = []; + let lastParticipantsToRender: ParticipantModel[] = []; const clearParticipantDragVisuals = () => { if (!participantsPanel) return; - participantsPanel.querySelectorAll('.participant-row').forEach((row) => { + participantsPanel.querySelectorAll<HTMLElement>('.participant-row').forEach((row) => { row.classList.remove('is-dragging', 'drag-over-before', 'drag-over-after'); }); }; - const moveParticipantToIndex = (participantId, targetIndex) => { + const moveParticipantToIndex = (participantId: string, targetIndex: number) => { const fromIndex = participantOrder.indexOf(participantId); if (fromIndex < 0) return false; @@ -159,7 +176,7 @@ export const createParticipantsPanelController = ({ applyParticipantsPanelVisibility(); }); - document.addEventListener('pointermove', (event) => { + document.addEventListener('pointermove', (event: PointerEvent) => { if (!draggedParticipantId || event.pointerId !== draggedPointerId) return; event.preventDefault(); @@ -201,7 +218,7 @@ export const createParticipantsPanelController = ({ targetRow.classList.add(after ? 'drag-over-after' : 'drag-over-before'); }); - document.addEventListener('pointerup', (event) => { + document.addEventListener('pointerup', (event: PointerEvent) => { if (!draggedParticipantId || event.pointerId !== draggedPointerId) return; const draggedId = draggedParticipantId; @@ -233,16 +250,16 @@ export const createParticipantsPanelController = ({ applyParticipantsPanelVisibility(); - const isParticipantVisible = (participantId) => participantVisibility[participantId] !== false; - const setParticipantVisibility = (participantId, visible) => { + const isParticipantVisible = (participantId: string) => participantVisibility[participantId] !== false; + const setParticipantVisibility = (participantId: string, visible: boolean) => { participantVisibility[participantId] = visible; localStorage.setItem(PARTICIPANT_VISIBILITY_STORAGE_KEY, JSON.stringify(participantVisibility)); }; - const getParticipantsToRender = (participants) => { - const participantsToRender = participants.filter((p) => p.id !== selfParticipant.id); + const getParticipantsToRender = (participants: ParticipantModel[]) => { + const participantsToRender = participants.filter((p: ParticipantModel) => p.id !== selfParticipant.id); - if (participantsToRender.findIndex((p) => p.id === northMarkerId) === -1) { + if (participantsToRender.findIndex((p: ParticipantModel) => p.id === northMarkerId) === -1) { participantsToRender.push({ id: northMarkerId, name: 'North', @@ -258,7 +275,7 @@ export const createParticipantsPanelController = ({ return sortParticipantsForDisplay(participantsToRender); }; - const renderParticipantControls = (participantsToRender) => { + const renderParticipantControls = (participantsToRender: ParticipantModel[]) => { lastParticipantsToRender = participantsToRender; if (!participantsPanel) return; diff --git a/src/qr.ts b/src/qr.ts index 0c49af2..31fdbfc 100644 --- a/src/qr.ts +++ b/src/qr.ts @@ -68,6 +68,12 @@ export const renderInviteQrCard = async ({ url, statusText, roomId +}: { + container: HTMLElement | null; + title: string; + url: string; + statusText: string; + roomId: string; }) => { if (!container) return; diff --git a/src/renderer.ts b/src/renderer.ts index 88bc29a..fe06f6c 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -3,6 +3,15 @@ import type { PresenceThresholds } from './utils'; type ArrowElement = HTMLElement & { currentAngle: number | null }; +type RenderableFriend = { + id: string; + name: string; + color: string; + position: { lat: number; lon: number }; + timestamp?: number; + lastSeen?: number; +}; + export const renderArrows = ({ content, arrow, @@ -12,12 +21,21 @@ export const renderArrows = ({ isFriendVisible, getArrowDisplayColor, presenceThresholds, +}: { + content: HTMLElement; + arrow: HTMLElement; + friendsToRender: RenderableFriend[]; + selfFriend: RenderableFriend; + selfHeading: number; + isFriendVisible: (friendId: string) => boolean; + getArrowDisplayColor: (hexColor: string) => string; + presenceThresholds: PresenceThresholds; }) => { const now = Date.now(); const activeArrowIds = new Set( friendsToRender - .filter((target) => target.id !== selfFriend.id) - .map((target) => `arrow-${target.id}`) + .filter((target: RenderableFriend) => target.id !== selfFriend.id) + .map((target: RenderableFriend) => `arrow-${target.id}`) ); for (const target of friendsToRender) { @@ -81,7 +99,7 @@ export const renderArrows = ({ } } - content.querySelectorAll('.arrow[id^="arrow-"]').forEach((arrowElement) => { + content.querySelectorAll<HTMLElement>('.arrow[id^="arrow-"]').forEach((arrowElement: HTMLElement) => { if (!activeArrowIds.has(arrowElement.id)) { arrowElement.remove(); } diff --git a/src/room.ts b/src/room.ts index 263f40e..fea2a5b 100644 --- a/src/room.ts +++ b/src/room.ts @@ -2,12 +2,12 @@ import { PUBLIC_SETTINGS } from './public-settings'; const ROOM_KEY_PATTERN = /^[A-Za-z0-9_-]+$/; -export const buildRoomHash = (nextRoomId, nextRoomKeyBase64 = null) => { +export const buildRoomHash = (nextRoomId: string, nextRoomKeyBase64: string | null = null) => { const keyPart = nextRoomKeyBase64 ? `.${nextRoomKeyBase64}` : ''; return `${nextRoomId}${keyPart}`; }; -export const parseRoomFromHash = (hashValue) => { +export const parseRoomFromHash = (hashValue: string) => { const normalizedHash = (hashValue || '').startsWith('#') ? hashValue.slice(1) : (hashValue || ''); const compactMatch = normalizedHash.match(new RegExp(`^([a-z0-9]{${PUBLIC_SETTINGS.room.idLength}})(?:\\.([A-Za-z0-9_-]+))?$`)); if (!compactMatch) return null; diff --git a/src/self-profile.ts b/src/self-profile.ts index e72ebce..0a4c868 100644 --- a/src/self-profile.ts +++ b/src/self-profile.ts @@ -43,6 +43,14 @@ export async function initSelfProfile({ selfPosition, syncSelf, requestRender +}: { + nameForm: HTMLElement | null; + selfNameInput: HTMLInputElement | null; + selfColorInput: HTMLInputElement | null; + uid: string; + selfPosition: { lat: number; lon: number }; + syncSelf: () => Promise<void>; + requestRender: () => void; }) { const initialSelfName = await resolveInitialSelfName(); const initialSelfColor = resolveInitialSelfColor(); @@ -58,7 +66,7 @@ export async function initSelfProfile({ if (selfNameInput) selfNameInput.value = selfFriend.name; if (selfColorInput) selfColorInput.value = selfFriend.color; - const applySelfName = async (rawName) => { + const applySelfName = async (rawName: string) => { const nextName = normalizeName(rawName) || await createRandomUserName(); if (nextName === selfFriend.name) { if (selfNameInput) selfNameInput.value = nextName; @@ -71,7 +79,7 @@ export async function initSelfProfile({ requestRender(); }; - const applySelfColor = async (rawColor) => { + const applySelfColor = async (rawColor: string) => { const nextColor = normalizeColor(rawColor) || createRandomColor(); if (nextColor === selfFriend.color) { if (selfColorInput) selfColorInput.value = nextColor; @@ -85,7 +93,7 @@ export async function initSelfProfile({ }; if (nameForm) { - nameForm.addEventListener('submit', async (event) => { + nameForm.addEventListener('submit', async (event: Event) => { event.preventDefault(); await applySelfName(selfNameInput ? selfNameInput.value : selfFriend.name); await applySelfColor(selfColorInput ? selfColorInput.value : selfFriend.color); diff --git a/src/session.ts b/src/session.ts index 5fefb3d..55640c0 100644 --- a/src/session.ts +++ b/src/session.ts @@ -15,10 +15,17 @@ export async function initSession({ isLocalCommit, BASE_ROOM_URL, GITLAB_PAGES_BASE_URL +}: { + roomCurrent: HTMLElement | null; + createRoomLink: HTMLAnchorElement | null; + inviteRoomQrList: HTMLElement | null; + isLocalCommit: boolean; + BASE_ROOM_URL: string; + GITLAB_PAGES_BASE_URL: string; }) { - let roomKey = null; - let roomKeyBase64 = null; - let roomId; + let roomKey: CryptoKey | null = null; + let roomKeyBase64: string | null = null; + let roomId: string; const parsedRoom = parseRoomFromHash(location.hash); if (parsedRoom) { diff --git a/src/utils.ts b/src/utils.ts index d569125..0d60b93 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,20 +1,29 @@ -export const getShortestDelta = (fromAngle, toAngle) => ((toAngle - fromAngle + 540) % 360) - 180; +type FriendLike = { + timestamp?: number; + lastSeen?: number; +}; + +type UserAgentDataWithModel = Navigator['userAgentData'] & { + getHighEntropyValues: (hints: string[]) => Promise<{ model?: string }>; +}; + +export const getShortestDelta = (fromAngle: number, toAngle: number) => ((toAngle - fromAngle + 540) % 360) - 180; -export const normalizeAngle = (angle) => ((angle % 360) + 360) % 360; +export const normalizeAngle = (angle: number) => ((angle % 360) + 360) % 360; -export const normalizeSignedAngle = (angle) => { +export const normalizeSignedAngle = (angle: number) => { const normalized = normalizeAngle(angle); return normalized > 180 ? normalized - 360 : normalized; }; -export const normalizeName = (value) => (value || '').trim().slice(0, 32); +export const normalizeName = (value: string | null | undefined) => (value || '').trim().slice(0, 32); -export const normalizeColor = (value) => { +export const normalizeColor = (value: string | null | undefined) => { const candidate = (value || '').trim().toLowerCase(); return /^#[0-9a-f]{6}$/.test(candidate) ? candidate : ''; }; -export const getReadableTextColor = (hexColor) => { +export const getReadableTextColor = (hexColor: string) => { const normalized = normalizeColor(hexColor); if (!normalized) return '#0f172a'; @@ -26,7 +35,7 @@ export const getReadableTextColor = (hexColor) => { return luminance > 145 ? '#0f172a' : '#f8fafc'; }; -export const getArrowDisplayColor = (hexColor) => { +export const getArrowDisplayColor = (hexColor: string) => { if (!document.body.classList.contains('dark')) return hexColor; const normalized = normalizeColor(hexColor); if (!normalized) return '#ffffff'; @@ -43,7 +52,7 @@ export type PresenceThresholds = { recentlyOfflineMs: number; }; -export const getFriendPresenceState = (friend, now: number, thresholds: PresenceThresholds): 'online' | 'recent-offline' | 'stale-offline' => { +export const getFriendPresenceState = (friend: FriendLike, now: number, thresholds: PresenceThresholds): 'online' | 'recent-offline' | 'stale-offline' => { const friendTimestamp = Number(friend.lastSeen ?? friend.timestamp); if (!Number.isFinite(friendTimestamp)) return 'stale-offline'; const ageMs = Math.max(0, now - friendTimestamp); @@ -59,9 +68,10 @@ export const PRESENCE_EMOJI: Record<string, string> = { }; export const getDeviceHint = async () => { - if (navigator.userAgentData) { + const userAgentData = (navigator as Navigator & { userAgentData?: UserAgentDataWithModel }).userAgentData; + if (userAgentData) { try { - const data = await navigator.userAgentData.getHighEntropyValues(['model']); + const data = await userAgentData.getHighEntropyValues(['model']); if (data.model) return data.model; } catch { // Fall through to UA parsing. diff --git a/src/wakelock.ts b/src/wakelock.ts index 07bdeb1..a0c606f 100644 --- a/src/wakelock.ts +++ b/src/wakelock.ts @@ -1,5 +1,5 @@ export const createWakeLockManager = () => { - let wakeLockSentinel = null; + let wakeLockSentinel: any = null; const hasWakeLockSupport = typeof navigator !== 'undefined' && 'wakeLock' in navigator; const requestWakeLock = async () => { @@ -8,7 +8,7 @@ export const createWakeLockManager = () => { if (wakeLockSentinel) return; try { - wakeLockSentinel = await navigator.wakeLock.request('screen'); + wakeLockSentinel = await (navigator as any).wakeLock.request('screen'); wakeLockSentinel.addEventListener('release', () => { wakeLockSentinel = null; if (document.visibilityState === 'visible') { diff --git a/tsconfig.json b/tsconfig.json index ed4f24c..b24e7d2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "vite/client" ], "allowJs": false, - "strict": false, + "strict": true, "isolatedModules": true, "noEmit": true, "skipLibCheck": true diff --git a/vite.config.js b/vite.config.js index 61da345..8c40294 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,8 +1,14 @@ import { defineConfig } from 'vite'; +import checker from 'vite-plugin-checker'; export default defineConfig({ root: 'src', base: './', + plugins: [ + checker({ + typescript: true, + }), + ], build: { target: 'esnext', outDir: '../public',