north-star / commit
added qr codes, reorganized ui a bit
mobile/App.tsx | 18 +- mobile/app.json | 6 +- mobile/package-lock.json | 565 +++++++++++++++++++++++++++++ mobile/package.json | 6 + mobile/src/components/qr_scanner_modal.tsx | 164 +++++++++ mobile/src/platform/profile.ts | 9 + mobile/src/screens/onboarding_screen.tsx | 230 ++++++++---- mobile/src/screens/room_screen.tsx | 5 - mobile/src/screens/room_session_screen.tsx | 230 +++++++----- 9 files changed, 1057 insertions(+), 176 deletions(-) diff --git a/mobile/App.tsx b/mobile/App.tsx index 72e6795..ec41f57 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react'; import { StatusBar } from 'expo-status-bar'; -import { SafeAreaView, StyleSheet, Text, View } from 'react-native'; +import { StyleSheet, Text, View } from 'react-native'; import type { SymmetricKey } from './src/core/crypto'; -import { loadProfile, saveProfile } from './src/platform/profile'; +import { loadProfile, saveProfile, loadLastRoom, saveLastRoom } from './src/platform/profile'; import { OnboardingScreen } from './src/screens/onboarding_screen'; import { RoomSessionScreen } from './src/screens/room_session_screen'; +import { SafeAreaView } from 'react-native-safe-area-context'; const createDefaultDisplayName = () => `mobile-${Math.floor(1000 + Math.random() * 9000)}`; @@ -12,13 +13,15 @@ export default function App() { const [displayName, setDisplayName] = useState(createDefaultDisplayName()); const [displayColor, setDisplayColor] = useState('#3366cc'); const [activeRoom, setActiveRoom] = useState<{ roomHash: string; roomKey: SymmetricKey | null } | null>(null); + const [lastRoomHash, setLastRoomHash] = useState<string | null>(null); useEffect(() => { - loadProfile().then((saved) => { + Promise.all([loadProfile(), loadLastRoom()]).then(([saved, lastHash]) => { if (saved) { setDisplayName(saved.name); setDisplayColor(saved.color); } + if (lastHash) setLastRoomHash(lastHash); }).catch(() => { /* ignore storage errors on load */ }); }, []); @@ -31,6 +34,12 @@ export default function App() { const isInSession = !!activeRoom; + const handleStartRoom = (params: { roomHash: string; roomKey: SymmetricKey | null }) => { + setLastRoomHash(params.roomHash); + void saveLastRoom(params.roomHash); + setActiveRoom(params); + }; + return ( <SafeAreaView style={styles.root}> <StatusBar style="dark" /> @@ -51,7 +60,8 @@ export default function App() { displayColor={displayColor} onChangeDisplayName={setDisplayName} onChangeDisplayColor={setDisplayColor} - onStartRoom={setActiveRoom} + onStartRoom={handleStartRoom} + lastRoomHash={lastRoomHash} /> )} {!!activeRoom && ( diff --git a/mobile/app.json b/mobile/app.json index 8545409..ce66433 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -14,13 +14,15 @@ "ios": { "supportsTablet": true, "infoPlist": { - "NSLocationWhenInUseUsageDescription": "Friend Compass uses your location to share it with others in your room." + "NSLocationWhenInUseUsageDescription": "Friend Compass uses your location to share it with others in your room.", + "NSCameraUsageDescription": "Friend Compass uses the camera to scan QR codes for joining rooms." } }, "android": { "permissions": [ "ACCESS_FINE_LOCATION", - "ACCESS_COARSE_LOCATION" + "ACCESS_COARSE_LOCATION", + "CAMERA" ], "adaptiveIcon": { "backgroundColor": "#E6F4FE", diff --git a/mobile/package-lock.json b/mobile/package-lock.json index 63711b7..1169940 100644 --- a/mobile/package-lock.json +++ b/mobile/package-lock.json @@ -12,12 +12,18 @@ "@react-native-async-storage/async-storage": "2.2.0", "base-64": "^1.0.0", "expo": "~54.0.0", + "expo-camera": "~17.0.10", "expo-location": "~19.0.8", "expo-sensors": "~15.0.8", "expo-status-bar": "~3.0.9", "firebase": "^12.0.0", "react": "19.1.0", + "react-dom": "19.1.0", "react-native": "0.81.5", + "react-native-qrcode-svg": "^6.3.21", + "react-native-safe-area-context": "^5.7.0", + "react-native-svg": "15.12.1", + "react-native-web": "^0.21.0", "tweetnacl": "^1.0.3" }, "devDependencies": { @@ -3650,6 +3656,12 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -4051,6 +4063,15 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4065,6 +4086,65 @@ "node": ">= 8" } }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "license": "MIT", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4089,6 +4169,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -4156,6 +4245,67 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -4210,6 +4360,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-editor": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", @@ -4338,6 +4500,26 @@ } } }, + "node_modules/expo-camera": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz", + "integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-location": { "version": "19.0.8", "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-19.0.8.tgz", @@ -4769,6 +4951,36 @@ "bser": "2.1.1" } }, + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "license": "MIT" + }, + "node_modules/fbjs/node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -5119,6 +5331,12 @@ "node": ">= 14" } }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -5201,6 +5419,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/inline-style-prefixer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz", + "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==", + "license": "MIT", + "dependencies": { + "css-in-js-utils": "^3.1.0" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -6009,6 +6236,12 @@ "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", "license": "Apache-2.0" }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -6464,6 +6697,26 @@ "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-forge": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", @@ -6509,6 +6762,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -6896,6 +7161,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -6974,6 +7245,23 @@ "node": ">= 6" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/protobufjs": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", @@ -7007,6 +7295,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qrcode-terminal": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz", @@ -7015,6 +7320,90 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, + "node_modules/qrcode/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -7067,6 +7456,18 @@ "ws": "^7" } }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -7140,6 +7541,79 @@ "react-native": "*" } }, + "node_modules/react-native-qrcode-svg": { + "version": "6.3.21", + "resolved": "https://registry.npmjs.org/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.21.tgz", + "integrity": "sha512-6vcj4rcdpWedvphDR+NSJcudJykNuLgNGFwm2p4xYjR8RdyTzlrELKI5LkO4ANS9cQUbqsfkpippPv64Q2tUtA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.0", + "qrcode": "^1.5.4", + "text-encoding": "^0.7.0" + }, + "peerDependencies": { + "react": "*", + "react-native": ">=0.63.4", + "react-native-svg": ">=14.0.0" + } + }, + "node_modules/react-native-safe-area-context": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", + "integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-svg": { + "version": "15.12.1", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz", + "integrity": "sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", + "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@react-native/normalize-colors": "^0.74.1", + "fbjs": "^3.0.4", + "inline-style-prefixer": "^7.0.1", + "memoize-one": "^6.0.0", + "nullthrows": "^1.1.1", + "postcss-value-parser": "^4.2.0", + "styleq": "^0.1.3" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-native-web/node_modules/@react-native/normalize-colors": { + "version": "0.74.89", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz", + "integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==", + "license": "MIT" + }, + "node_modules/react-native-web/node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/react-native/node_modules/@react-native/virtualized-lists": { "version": "0.81.5", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz", @@ -7310,6 +7784,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requireg": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/requireg/-/requireg-0.2.2.tgz", @@ -7597,6 +8077,18 @@ "node": ">= 0.8" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -7818,6 +8310,12 @@ "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "license": "MIT" }, + "node_modules/styleq": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz", + "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -8008,6 +8506,13 @@ "node": "*" } }, + "node_modules/text-encoding": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.7.0.tgz", + "integrity": "sha512-oJQ3f1hrOnbRLOcwKz0Liq2IcrvDeZRHXhd9RgLrsT+DjWY/nty1Hi7v3dtkaEYbPYe0mUoOfzRrMwfXXwgPUA==", + "deprecated": "no longer maintained", + "license": "(Unlicense OR Apache-2.0)" + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -8107,6 +8612,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -8157,6 +8668,32 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", @@ -8302,6 +8839,12 @@ "makeerror": "1.0.12" } }, + "node_modules/warn-once": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", + "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", + "license": "MIT" + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -8355,6 +8898,16 @@ "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT" }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/whatwg-url-without-unicode": { "version": "8.0.0-3", "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", @@ -8369,6 +8922,12 @@ "node": ">=10" } }, + "node_modules/whatwg-url/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8384,6 +8943,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wonka": { "version": "6.3.5", "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.5.tgz", diff --git a/mobile/package.json b/mobile/package.json index e4e3cb7..c2290bd 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -14,12 +14,18 @@ "@react-native-async-storage/async-storage": "2.2.0", "base-64": "^1.0.0", "expo": "~54.0.0", + "expo-camera": "~17.0.10", "expo-location": "~19.0.8", "expo-sensors": "~15.0.8", "expo-status-bar": "~3.0.9", "firebase": "^12.0.0", "react": "19.1.0", + "react-dom": "19.1.0", "react-native": "0.81.5", + "react-native-qrcode-svg": "^6.3.21", + "react-native-safe-area-context": "^5.7.0", + "react-native-svg": "15.12.1", + "react-native-web": "^0.21.0", "tweetnacl": "^1.0.3" }, "devDependencies": { diff --git a/mobile/src/components/qr_scanner_modal.tsx b/mobile/src/components/qr_scanner_modal.tsx new file mode 100644 index 0000000..c973bc0 --- /dev/null +++ b/mobile/src/components/qr_scanner_modal.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from 'react'; +import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'; +import { CameraView, useCameraPermissions } from 'expo-camera'; + +type QrScannerModalProps = { + visible: boolean; + onScan: (data: string) => void; + onClose: () => void; +}; + +export function QrScannerModal({ visible, onScan, onClose }: QrScannerModalProps) { + const [permission, requestPermission] = useCameraPermissions(); + const [scanned, setScanned] = useState(false); + + // Reset scanned state each time the modal opens + useEffect(() => { + if (visible) setScanned(false); + }, [visible]); + + const handleBarcodeScanned = ({ data }: { data: string }) => { + if (scanned) return; + setScanned(true); + onScan(data); + }; + + const handleRequestPermission = () => { + void requestPermission(); + }; + + return ( + <Modal visible={visible} animationType="slide" onRequestClose={onClose}> + <View style={styles.root}> + <View style={styles.header}> + <Text style={styles.title}>Scan QR Code</Text> + <Pressable onPress={onClose} style={({ pressed }) => [styles.closeButton, pressed && styles.pressed]}> + <Text style={styles.closeText}>Cancel</Text> + </Pressable> + </View> + + {!permission && ( + <View style={styles.centeredContent}> + <Text style={styles.message}>Checking camera permission…</Text> + </View> + )} + + {permission && !permission.granted && ( + <View style={styles.centeredContent}> + <Text style={styles.message}>Camera access is needed to scan QR codes.</Text> + <Pressable + onPress={handleRequestPermission} + style={({ pressed }) => [styles.permissionButton, pressed && styles.pressed]} + > + <Text style={styles.permissionButtonText}>Allow Camera</Text> + </Pressable> + </View> + )} + + {permission?.granted && ( + <View style={styles.cameraWrap}> + <CameraView + style={styles.camera} + facing="back" + barcodeScannerSettings={{ barcodeTypes: ['qr'] }} + onBarcodeScanned={scanned ? undefined : handleBarcodeScanned} + /> + <View style={styles.overlay}> + <View style={styles.scanFrame} /> + </View> + <Text style={styles.hint}> + {scanned ? 'Processing…' : 'Point the camera at a Friend Compass QR code'} + </Text> + </View> + )} + </View> + </Modal> + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: '#111' + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingTop: 60, + paddingBottom: 16, + backgroundColor: '#1a1a1a' + }, + title: { + fontSize: 18, + fontWeight: '700', + color: '#f2ede5' + }, + closeButton: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + backgroundColor: '#333' + }, + closeText: { + color: '#f2ede5', + fontSize: 14, + fontWeight: '600' + }, + pressed: { + opacity: 0.7 + }, + centeredContent: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + gap: 20, + padding: 32 + }, + message: { + color: '#ccc', + fontSize: 15, + textAlign: 'center' + }, + permissionButton: { + backgroundColor: '#7a4f2a', + borderRadius: 10, + paddingHorizontal: 24, + paddingVertical: 12 + }, + permissionButtonText: { + color: '#f7eee2', + fontSize: 15, + fontWeight: '700' + }, + cameraWrap: { + flex: 1, + position: 'relative' + }, + camera: { + flex: 1 + }, + overlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center' + }, + scanFrame: { + width: 220, + height: 220, + borderRadius: 12, + borderWidth: 2, + borderColor: 'rgba(255, 220, 160, 0.85)' + }, + hint: { + position: 'absolute', + bottom: 48, + left: 0, + right: 0, + textAlign: 'center', + color: '#f2ede5', + fontSize: 14, + paddingHorizontal: 24 + } +}); diff --git a/mobile/src/platform/profile.ts b/mobile/src/platform/profile.ts index 34984d9..208bec8 100644 --- a/mobile/src/platform/profile.ts +++ b/mobile/src/platform/profile.ts @@ -2,6 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; const KEY_NAME = 'profile:name'; const KEY_COLOR = 'profile:color'; +const KEY_LAST_ROOM = 'room:last_hash'; export const loadProfile = async (): Promise<{ name: string; color: string } | null> => { const [name, color] = await AsyncStorage.multiGet([KEY_NAME, KEY_COLOR]); @@ -17,3 +18,11 @@ export const saveProfile = async (name: string, color: string): Promise<void> => [KEY_COLOR, color], ]); }; + +export const loadLastRoom = async (): Promise<string | null> => { + return AsyncStorage.getItem(KEY_LAST_ROOM); +}; + +export const saveLastRoom = async (roomHash: string): Promise<void> => { + await AsyncStorage.setItem(KEY_LAST_ROOM, roomHash); +}; diff --git a/mobile/src/screens/onboarding_screen.tsx b/mobile/src/screens/onboarding_screen.tsx index 2087e99..7c2070b 100644 --- a/mobile/src/screens/onboarding_screen.tsx +++ b/mobile/src/screens/onboarding_screen.tsx @@ -1,8 +1,16 @@ import { useState } from 'react'; import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; + +const PRESET_COLORS = [ + '#e53935', '#f06292', '#ab47bc', '#7986cb', '#42a5f5', + '#26c6da', '#26a69a', '#66bb6a', '#d4e157', '#ffca28', + '#ffa726', '#ff7043', '#8d6e63', '#78909c', '#546e7a', + '#3366cc', '#6a1b9a', '#00695c', '#558b2f', '#4e342e', +]; import { generateRoomKey, exportRoomKey, importRoomKey, type SymmetricKey } from '../core/crypto'; import { buildRoomHash, parseRoomFromHash } from '../core/room'; import { PUBLIC_SETTINGS } from '../public_settings'; +import { QrScannerModal } from '../components/qr_scanner_modal'; type OnboardingScreenProps = { displayName: string; @@ -10,6 +18,7 @@ type OnboardingScreenProps = { onChangeDisplayName: (name: string) => void; onChangeDisplayColor: (color: string) => void; onStartRoom: (params: { roomHash: string; roomKey: SymmetricKey | null }) => void; + lastRoomHash: string | null; }; const randomRoomId = () => { @@ -26,10 +35,12 @@ export function OnboardingScreen({ displayColor, onChangeDisplayName, onChangeDisplayColor, - onStartRoom + onStartRoom, + lastRoomHash }: OnboardingScreenProps) { - const [roomHashInput, setRoomHashInput] = useState(''); const [joinError, setJoinError] = useState<string | null>(null); + const [rejoinError, setRejoinError] = useState<string | null>(null); + const [showScanner, setShowScanner] = useState(false); const createRoom = async () => { setJoinError(null); @@ -39,91 +50,120 @@ export function OnboardingScreen({ const hash = buildRoomHash(roomId, keyB64); const webAppUrl = `http://localhost:5173/#${hash}`; - console.log(`Room created. Join from web: ${webAppUrl}`); onStartRoom({ roomHash: hash, roomKey: key }); }; - const joinRoom = async () => { - setJoinError(null); - const normalized = roomHashInput.trim().replace(/^#/, ''); + // Accept either a plain room hash or a full URL (as produced by the web app QR codes) + const extractHash = (raw: string): string => { + const trimmed = raw.trim(); + try { + const url = new URL(trimmed); + return url.hash.replace(/^#/, ''); + } catch { + return trimmed.replace(/^#/, ''); + } + }; + + const joinRoomFromHash = async (rawHash: string, setError: (e: string | null) => void) => { + setError(null); + const normalized = extractHash(rawHash); const parsed = parseRoomFromHash(normalized); if (!parsed) { - setJoinError('Invalid room hash.'); + setError('Invalid room hash.'); return; } - const hash = buildRoomHash(parsed.roomId, parsed.roomKeyBase64); const roomKey = parsed.roomKeyBase64 ? await importRoomKey(parsed.roomKeyBase64) : null; onStartRoom({ roomHash: hash, roomKey }); }; + const rejoinRoom = () => void joinRoomFromHash(lastRoomHash ?? '', setRejoinError); + const handleQrScanned = (data: string) => { + setShowScanner(false); + void joinRoomFromHash(data, setJoinError); + }; + return ( - <ScrollView contentContainerStyle={styles.container}> - <View style={styles.heroCard}> - <View style={styles.heroOrbA} /> - <View style={styles.heroOrbB} /> - <Text style={styles.heroEyebrow}>NORTH STAR</Text> - <Text style={styles.title}>Friend Compass</Text> - <Text style={styles.subtitle}>Set your profile, then create or join a room to start live direction sync.</Text> - </View> + <> + <QrScannerModal + visible={showScanner} + onScan={handleQrScanned} + onClose={() => setShowScanner(false)} + /> + <ScrollView contentContainerStyle={styles.container}> + <View style={styles.heroCard}> + <View style={styles.heroOrbA} /> + <View style={styles.heroOrbB} /> + <Text style={styles.title}>Friend Compass</Text> + <Text style={styles.subtitle}>Set your profile, then create or join a room to start live direction sync.</Text> + </View> - <View style={styles.card}> - <View style={styles.profileHeaderRow}> - <Text style={styles.label}>Step 1: Your profile</Text> - <View style={[styles.colorPreview, { backgroundColor: displayColor || '#3366cc' }]} /> + <View style={styles.card}> + <Text style={styles.label}>Your Profile</Text> + <Text style={styles.helperText}>Pick a name and color that others will see in the room.</Text> + <TextInput + value={displayName} + onChangeText={onChangeDisplayName} + autoCapitalize="none" + autoCorrect={false} + placeholder="display name" + placeholderTextColor="#7a7469" + style={styles.input} + /> + <View style={styles.colorPickerRow}> + <View style={[styles.colorPreviewLarge, { backgroundColor: displayColor || '#3366cc' }]} /> + <View style={styles.colorGrid}> + {PRESET_COLORS.map((color) => ( + <Pressable + key={color} + onPress={() => onChangeDisplayColor(color)} + style={[ + styles.colorSwatch, + { backgroundColor: color }, + displayColor === color && styles.colorSwatchSelected + ]} + /> + ))} + </View> + </View> </View> - <TextInput - value={displayName} - onChangeText={onChangeDisplayName} - autoCapitalize="none" - autoCorrect={false} - placeholder="display name" - placeholderTextColor="#7a7469" - style={styles.input} - /> - <TextInput - value={displayColor} - onChangeText={onChangeDisplayColor} - autoCapitalize="none" - autoCorrect={false} - placeholder="#3366cc" - placeholderTextColor="#7a7469" - style={styles.input} - /> - <Text style={styles.helperText}>Pick a name and color that others will see in the room.</Text> - </View> - <View style={styles.createCard}> - <Text style={styles.label}>Step 2A: Create room</Text> - <Text style={styles.helperText}>Creates a fresh encrypted room hash for sharing.</Text> - <Pressable onPress={() => void createRoom()} style={({ pressed }) => [styles.buttonPrimary, pressed && styles.buttonPressed]}> - <Text style={styles.buttonText}>Create encrypted room</Text> - </Pressable> - </View> + {!!lastRoomHash && ( + <View style={styles.rejoinCard}> + <Text style={styles.label}>Rejoin Last Room</Text> + <Text style={styles.helperText}>You were in a room recently. Jump back in.</Text> + <Pressable onPress={rejoinRoom} style={({ pressed }) => [styles.buttonRejoin, pressed && styles.buttonPressed]}> + <Text style={styles.buttonText}>Rejoin Room</Text> + </Pressable> + {!!rejoinError && <Text style={styles.errorText}>{rejoinError}</Text>} + </View> + )} - <View style={styles.joinCard}> - <Text style={styles.label}>Step 2B: Join existing room</Text> - <Text style={styles.helperText}>Paste an invite hash from web or another phone.</Text> - <TextInput - value={roomHashInput} - onChangeText={setRoomHashInput} - autoCapitalize="none" - autoCorrect={false} - placeholder="example: ab12cd34" - placeholderTextColor="#7a7469" - style={styles.input} - /> - <Pressable onPress={() => void joinRoom()} style={({ pressed }) => [styles.buttonSecondary, pressed && styles.buttonPressed]}> - <Text style={styles.buttonText}>Join room</Text> - </Pressable> - {!!joinError && <Text style={styles.errorText}>{joinError}</Text>} - </View> + <View style={styles.joinCard}> + <Text style={styles.label}>Join Existing Room</Text> + <Text style={styles.helperText}>Scan a QR code from the web app or another phone.</Text> + <Pressable + onPress={() => setShowScanner(true)} + style={({ pressed }) => [styles.buttonQrScan, pressed && styles.buttonPressed]} + > + <Text style={styles.buttonText}>📷 Scan QR code</Text> + </Pressable> + {!!joinError && <Text style={styles.errorText}>{joinError}</Text>} + </View> - <Text style={styles.footerNote}>Tip: after creating a room, the app logs a web URL in the console for quick laptop join.</Text> - </ScrollView> + <View style={styles.createCard}> + <Text style={styles.label}>Create Room</Text> + <Text style={styles.helperText}>Creates a fresh encrypted room hash for sharing.</Text> + <Pressable onPress={() => void createRoom()} style={({ pressed }) => [styles.buttonPrimary, pressed && styles.buttonPressed]}> + <Text style={styles.buttonText}>Create Room</Text> + </Pressable> + </View> + </ScrollView> + </> ); -} +}; + const styles = StyleSheet.create({ container: { @@ -189,6 +229,14 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: '#cac0ac' }, + rejoinCard: { + backgroundColor: '#e0ddf5', + borderRadius: 14, + padding: 16, + gap: 10, + borderWidth: 1, + borderColor: '#b8b3e0' + }, joinCard: { backgroundColor: '#e5d8c4', borderRadius: 14, @@ -197,17 +245,35 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: '#ccb798' }, - profileHeaderRow: { + colorPickerRow: { flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center' + alignItems: 'flex-start', + gap: 12 }, - colorPreview: { - width: 18, - height: 18, - borderRadius: 999, - borderWidth: 1, - borderColor: '#8f8778' + colorPreviewLarge: { + width: 44, + height: 44, + borderRadius: 22, + borderWidth: 2, + borderColor: '#8f8778', + flexShrink: 0 + }, + colorGrid: { + flex: 1, + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8 + }, + colorSwatch: { + width: 28, + height: 28, + borderRadius: 14, + borderWidth: 2, + borderColor: 'transparent' + }, + colorSwatchSelected: { + borderColor: '#2e2a23', + transform: [{ scale: 1.18 }] }, label: { fontSize: 14, @@ -237,6 +303,18 @@ const styles = StyleSheet.create({ alignItems: 'center', paddingVertical: 12 }, + buttonQrScan: { + backgroundColor: '#3d5a8a', + borderRadius: 10, + alignItems: 'center', + paddingVertical: 12 + }, + buttonRejoin: { + backgroundColor: '#4a4a8a', + borderRadius: 10, + alignItems: 'center', + paddingVertical: 12 + }, buttonPressed: { opacity: 0.85 }, diff --git a/mobile/src/screens/room_screen.tsx b/mobile/src/screens/room_screen.tsx index a2b9021..b217828 100644 --- a/mobile/src/screens/room_screen.tsx +++ b/mobile/src/screens/room_screen.tsx @@ -86,11 +86,6 @@ export function RoomScreen() { const hash = buildRoomHash(roomId, keyB64); setActiveRoomHash(hash); setRoomHashInput(''); - - // Log the URL for the web app to join this room - const webAppUrl = `http://localhost:5173/#${hash}`; - console.log(`📱 Encrypted room created! Join on web app:\n${webAppUrl}`); - console.log(`Replace localhost:5173 with your web app URL if needed.`); }; const joinFromInput = async () => { diff --git a/mobile/src/screens/room_session_screen.tsx b/mobile/src/screens/room_session_screen.tsx index be5e840..6bb2efd 100644 --- a/mobile/src/screens/room_session_screen.tsx +++ b/mobile/src/screens/room_session_screen.tsx @@ -7,6 +7,7 @@ import { Text, View } from 'react-native'; +import QRCode from 'react-native-qrcode-svg'; import { formatDistance, getBearingDeg, getDistanceMeters, getRelativeBearingDeg } from '../core/bearing'; import { parseRoomFromHash } from '../core/room'; import type { SymmetricKey } from '../core/crypto'; @@ -14,6 +15,7 @@ import { watchHeading } from '../platform/heading'; import { subscribeToFriends, upsertSelfFriend, type MobileFriendRecord } from '../features/friends/sync'; import { initFirebaseSession, type FirebaseSession } from '../platform/firebase'; import { requestLocationPermission, watchPosition, type LatLon } from '../platform/geolocation'; +import { PUBLIC_SETTINGS } from '../public_settings'; const MOBILE_SYNC_INTERVAL_MS = 10000; @@ -41,35 +43,55 @@ export function RoomSessionScreen({ onLeaveRoom }: RoomSessionScreenProps) { const [firebaseSession, setFirebaseSession] = useState<FirebaseSession | null>(null); - const [isConnectingFirebase, setIsConnectingFirebase] = useState(false); - const [firebaseError, setFirebaseError] = useState<string | null>(null); - - const [isRoomSyncEnabled, setIsRoomSyncEnabled] = useState(false); const [friends, setFriends] = useState<MobileFriendRecord[]>([]); - const [roomSyncError, setRoomSyncError] = useState<string | null>(null); + const [syncError, setSyncError] = useState<string | null>(null); const [position, setPosition] = useState<LatLon | null>(null); const [locationStatus, setLocationStatus] = useState<'idle' | 'denied' | 'watching'>('idle'); const [headingDeg, setHeadingDeg] = useState(0); + const [showQr, setShowQr] = useState(false); + const [customOrder, setCustomOrder] = useState<string[]>([]); const parsedRoom = useMemo(() => parseRoomFromHash(roomHash), [roomHash]); + const inviteUrl = `${PUBLIC_SETTINGS.links.gitlabPagesBaseUrl}#${roomHash}`; - const connectFirebase = async () => { - setIsConnectingFirebase(true); - setFirebaseError(null); - try { - const session = await initFirebaseSession(); - setFirebaseSession(session); - } catch (error) { - const nextError = error instanceof Error ? error.message : 'Unknown Firebase error'; - setFirebaseError(nextError); - } finally { - setIsConnectingFirebase(false); - } + const orderedFriends = useMemo(() => { + const knownIds = customOrder.filter(id => friends.some(f => f.id === id)); + const newFriends = friends.filter(f => !customOrder.includes(f.id)); + return [ + ...knownIds.map(id => friends.find(f => f.id === id)!), + ...newFriends + ]; + }, [friends, customOrder]); + + const moveUp = (index: number) => { + if (index <= 0) return; + const ids = orderedFriends.map(f => f.id); + const next = [...ids]; + [next[index - 1], next[index]] = [next[index], next[index - 1]]; + setCustomOrder(next); }; + const moveDown = (index: number) => { + if (index >= orderedFriends.length - 1) return; + const ids = orderedFriends.map(f => f.id); + const next = [...ids]; + [next[index], next[index + 1]] = [next[index + 1], next[index]]; + setCustomOrder(next); + }; + + // Auto-connect Firebase on mount useEffect(() => { - if (!isRoomSyncEnabled) return; + let disposed = false; + initFirebaseSession() + .then((session) => { if (!disposed) setFirebaseSession(session); }) + .catch((error) => { + if (!disposed) setSyncError(error instanceof Error ? error.message : 'Firebase connection failed'); + }); + return () => { disposed = true; }; + }, []); + // Start location watching on mount + useEffect(() => { let stopWatching: (() => void) | null = null; const start = async () => { @@ -81,7 +103,7 @@ export function RoomSessionScreen({ setLocationStatus('watching'); stopWatching = watchPosition( (nextPosition) => setPosition(nextPosition), - (message) => setRoomSyncError(message) + (message) => setSyncError(message) ); }; @@ -91,19 +113,19 @@ export function RoomSessionScreen({ stopWatching?.(); setLocationStatus('idle'); }; - }, [isRoomSyncEnabled]); + }, []); + // Start heading watching on mount useEffect(() => { - if (!isRoomSyncEnabled) return; - const stop = watchHeading( + return watchHeading( (deg) => setHeadingDeg(deg), - (msg) => setRoomSyncError(msg) + (msg) => setSyncError(msg) ); - return stop; - }, [isRoomSyncEnabled]); + }, []); + // Firebase sync when session is ready useEffect(() => { - if (!isRoomSyncEnabled || !firebaseSession || !parsedRoom) { + if (!firebaseSession || !parsedRoom) { setFriends([]); return; } @@ -124,11 +146,11 @@ export function RoomSessionScreen({ }); } catch (error) { const nextError = error instanceof Error ? error.message : 'Unknown room sync write error'; - if (!isDisposed) setRoomSyncError(nextError); + if (!isDisposed) setSyncError(nextError); } }; - setRoomSyncError(null); + setSyncError(null); if (position) void writeSelf(position); const intervalId = setInterval(() => { @@ -143,7 +165,7 @@ export function RoomSessionScreen({ if (!isDisposed) setFriends(nextFriends); }, onError: (error) => { - if (!isDisposed) setRoomSyncError(error.message); + if (!isDisposed) setSyncError(error.message); } }); @@ -152,7 +174,16 @@ export function RoomSessionScreen({ clearInterval(intervalId); unsubscribe(); }; - }, [displayColor, displayName, firebaseSession, isRoomSyncEnabled, parsedRoom, position, roomKey]); + }, [displayColor, displayName, firebaseSession, parsedRoom, position, roomKey]); + + // Absorb newly-seen friends into the custom order + useEffect(() => { + setCustomOrder(prev => { + const newIds = friends.filter(f => !prev.includes(f.id)).map(f => f.id); + if (newIds.length === 0) return prev; + return [...prev, ...newIds]; + }); + }, [friends]); return ( <ScrollView contentContainerStyle={styles.container}> @@ -169,54 +200,39 @@ export function RoomSessionScreen({ </View> </View> - <View style={styles.resultCard}> - <Text style={styles.label}>Live room details</Text> - <View> - <Text style={styles.value}>Hash: {roomHash}</Text> - <Text style={styles.value}>Encryption: {roomKey ? 'enabled' : 'none'}</Text> - </View> - </View> - <View style={styles.card}> - <Text style={styles.label}>Firebase session</Text> <Pressable - onPress={connectFirebase} - disabled={isConnectingFirebase} - style={({ pressed }) => [styles.button, (pressed || isConnectingFirebase) && styles.buttonPressed]} + onPress={() => setShowQr((v) => !v)} + style={({ pressed }) => [styles.qrToggleRow, pressed && styles.buttonPressed]} > - <Text style={styles.buttonText}> - {isConnectingFirebase ? 'Connecting...' : firebaseSession ? 'Reconnect Firebase' : 'Connect Firebase'} - </Text> + <Text style={styles.label}>Invite others</Text> + <Text style={styles.qrToggleChevron}>{showQr ? '▲' : '▼'}</Text> </Pressable> - {isConnectingFirebase && <ActivityIndicator color="#7a4f2a" />} - {!!firebaseSession && <Text style={styles.value}>Signed in uid: {firebaseSession.uid}</Text>} - {!!firebaseError && <Text style={styles.errorText}>Firebase error: {firebaseError}</Text>} + {showQr && ( + <View style={styles.qrWrap}> + <QRCode value={inviteUrl} size={200} /> + <Text style={styles.valueMuted}>Scan to join on web or mobile</Text> + </View> + )} </View> <View style={styles.card}> - <Text style={styles.label}>Live sync</Text> - <Pressable - onPress={() => setIsRoomSyncEnabled((prev) => !prev)} - disabled={!firebaseSession || !parsedRoom} - style={({ pressed }) => [ - styles.button, - (!firebaseSession || !parsedRoom || pressed) && styles.buttonPressed - ]} - > - <Text style={styles.buttonText}> - {isRoomSyncEnabled ? 'Stop room sync' : 'Start room sync'} - </Text> - </Pressable> - {!firebaseSession && <Text style={styles.valueMuted}>Connect Firebase first.</Text>} - {!!firebaseSession && !parsedRoom && <Text style={styles.valueMuted}>Invalid room hash.</Text>} - {locationStatus === 'denied' && <Text style={styles.errorText}>Location permission denied.</Text>} - {locationStatus === 'watching' && ( - position - ? <Text style={styles.value}>Location: {position.lat.toFixed(5)}, {position.lon.toFixed(5)}</Text> - : <Text style={styles.valueMuted}>Waiting for GPS fix...</Text> + <Text style={styles.label}> + {orderedFriends.length > 0 ? `Friends (${orderedFriends.length})` : 'Friends'} + </Text> + {!firebaseSession && !syncError && ( + <View style={styles.connectingRow}> + <ActivityIndicator size="small" color="#7a4f2a" /> + <Text style={styles.valueMuted}>Connecting to room...</Text> + </View> )} - <Text style={styles.value}>Participants in room: {friends.length}</Text> - {friends.map((friend) => { + {locationStatus === 'denied' && ( + <Text style={styles.errorText}>Location permission denied.</Text> + )} + {locationStatus === 'watching' && !position && ( + <Text style={styles.valueMuted}>Waiting for GPS fix...</Text> + )} + {orderedFriends.map((friend, index) => { const bearingDeg = position && friend.position ? getBearingDeg(position, friend.position) : null; @@ -234,10 +250,28 @@ export function RoomSessionScreen({ {arrowDeg !== null && ( <Text style={[styles.friendArrow, { transform: [{ rotate: `${arrowDeg}deg` }] }]}>↑</Text> )} + {orderedFriends.length > 1 && ( + <View style={styles.moveButtons}> + <Pressable + onPress={() => moveUp(index)} + disabled={index === 0} + style={[styles.moveButton, index === 0 && styles.moveButtonDim]} + > + <Text style={styles.moveButtonText}>▲</Text> + </Pressable> + <Pressable + onPress={() => moveDown(index)} + disabled={index === orderedFriends.length - 1} + style={[styles.moveButton, index === orderedFriends.length - 1 && styles.moveButtonDim]} + > + <Text style={styles.moveButtonText}>▼</Text> + </Pressable> + </View> + )} </View> ); })} - {!!roomSyncError && <Text style={styles.errorText}>Room sync error: {roomSyncError}</Text>} + {!!syncError && <Text style={styles.errorText}>{syncError}</Text>} </View> </ScrollView> ); @@ -289,25 +323,30 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: '#d7ccb9' }, - resultCard: { - backgroundColor: '#dbe3df', - borderRadius: 14, - padding: 16, - gap: 6, - borderWidth: 1, - borderColor: '#c2d1cb' + connectingRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8 + }, + qrToggleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center' + }, + qrToggleChevron: { + fontSize: 12, + color: '#5b554c' + }, + qrWrap: { + alignItems: 'center', + gap: 10, + paddingTop: 4 }, label: { fontSize: 14, fontWeight: '600', color: '#2e2a23' }, - button: { - backgroundColor: '#7a4f2a', - borderRadius: 10, - alignItems: 'center', - paddingVertical: 12 - }, secondaryButton: { borderWidth: 1, borderColor: '#7a4f2a', @@ -324,11 +363,6 @@ const styles = StyleSheet.create({ buttonPressed: { opacity: 0.85 }, - buttonText: { - color: '#f7eee2', - fontSize: 15, - fontWeight: '700' - }, value: { fontSize: 14, color: '#2e2a23' @@ -361,5 +395,23 @@ const styles = StyleSheet.create({ friendArrow: { fontSize: 24, color: '#2e2a23' + }, + moveButtons: { + flexDirection: 'column', + gap: 2 + }, + moveButton: { + paddingHorizontal: 6, + paddingVertical: 3, + borderRadius: 4, + backgroundColor: '#d7ccb9' + }, + moveButtonDim: { + opacity: 0.3 + }, + moveButtonText: { + fontSize: 10, + color: '#2e2a23', + lineHeight: 12 } });