fuzzyworld

north-star / commit

starting to make the arrow panel

3266273722d4141dcd7cd8d48cc3417e0110e16c · Pavel Safronov · 2026-03-18 16:53:26 -0700 · parents 21e6d2df1f60

starting to make the arrow panel
 mobile/assets/compass.png                  | Bin 0 -> 18747 bytes
 mobile/src/components/arrows_panel.tsx     | 166 ++++++++++++++++++++++++
 mobile/src/features/friends/sync.ts        |   4 +-
 mobile/src/platform/firebase.ts            |  10 +-
 mobile/src/screens/onboarding_screen.tsx   |  36 +++---
 mobile/src/screens/room_screen.tsx         |   5 +
 mobile/src/screens/room_session_screen.tsx | 194 ++++++++++++++++++++---------
 7 files changed, 331 insertions(+), 84 deletions(-)

diff --git a/mobile/assets/compass.png b/mobile/assets/compass.png
new file mode 100644
index 0000000..632ccd1
Binary files /dev/null and b/mobile/assets/compass.png differ
diff --git a/mobile/src/components/arrows_panel.tsx b/mobile/src/components/arrows_panel.tsx
new file mode 100644
index 0000000..eec655c
--- /dev/null
+++ b/mobile/src/components/arrows_panel.tsx
@@ -0,0 +1,166 @@
+import { StyleSheet, Text, View } from 'react-native';
+import Svg, { G, Path, Circle, Line } from 'react-native-svg';
+
+export type ArrowFriend = {
+    id: string;
+    name: string;
+    color: string;
+    arrowDeg: number | null;
+    dist: string | null;
+};
+
+type ArrowsPanelProps = {
+    friends: ArrowFriend[];
+};
+
+// Arrow needle tip pointing up (north), centered at (0,0).
+// Drawn in a ±1 unit coordinate space, scaled via SVG transform.
+const NEEDLE_TIP = 'M 0 -36 L 8 -10 L 0 -16 L -8 -10 Z';
+const NEEDLE_TAIL = 'M 0 16 L 5 6 L 0 10 L -5 6 Z';
+
+const COMPASS_SIZE = 260;
+const CENTER = COMPASS_SIZE / 2;
+
+export function ArrowsPanel({ friends }: ArrowsPanelProps) {
+    if (friends.length === 0) {
+        return (
+            <View style={styles.emptyWrap}>
+                <Text style={styles.emptyText}>Waiting for friends to join…</Text>
+            </View>
+        );
+    }
+
+    const hasNoPosition = friends.every(f => f.arrowDeg === null);
+
+    return (
+        <View style={styles.wrap}>
+            {/* Single shared compass face */}
+            <View style={styles.compassOuter}>
+                <Svg width={COMPASS_SIZE} height={COMPASS_SIZE} viewBox={`0 0 ${COMPASS_SIZE} ${COMPASS_SIZE}`}>
+                    {/* Compass ring */}
+                    <Circle
+                        cx={CENTER}
+                        cy={CENTER}
+                        r={CENTER - 4}
+                        fill="rgba(0,0,0,0.04)"
+                        stroke="rgba(0,0,0,0.12)"
+                        strokeWidth={2}
+                    />
+                    {/* Cardinal tick marks */}
+                    {[0, 90, 180, 270].map(deg => {
+                        const rad = (deg - 90) * (Math.PI / 180);
+                        const inner = CENTER - 20;
+                        const outer = CENTER - 6;
+                        return (
+                            <Line
+                                key={deg}
+                                x1={CENTER + inner * Math.cos(rad)}
+                                y1={CENTER + inner * Math.sin(rad)}
+                                x2={CENTER + outer * Math.cos(rad)}
+                                y2={CENTER + outer * Math.sin(rad)}
+                                stroke="rgba(0,0,0,0.2)"
+                                strokeWidth={2}
+                            />
+                        );
+                    })}
+                    {/* Center dot */}
+                    <Circle cx={CENTER} cy={CENTER} r={6} fill="rgba(0,0,0,0.15)" />
+
+                    {/* One needle per friend */}
+                    {friends.map((friend) => {
+                        if (friend.arrowDeg === null) return null;
+                        return (
+                            <G key={friend.id} transform={`translate(${CENTER}, ${CENTER}) rotate(${friend.arrowDeg})`}>
+                                {/* Tip */}
+                                <Path d={NEEDLE_TIP} fill={friend.color} opacity={0.92} />
+                                {/* Tail */}
+                                <Path d={NEEDLE_TAIL} fill={friend.color} opacity={0.5} />
+                            </G>
+                        );
+                    })}
+
+                    {hasNoPosition && (
+                        <Circle
+                            cx={CENTER}
+                            cy={CENTER}
+                            r={30}
+                            fill="none"
+                            stroke="rgba(0,0,0,0.15)"
+                            strokeWidth={3}
+                            strokeDasharray="8 5"
+                        />
+                    )}
+                </Svg>
+            </View>
+
+            {/* Legend: one row per friend */}
+            <View style={styles.legend}>
+                {friends.map((friend) => (
+                    <View key={friend.id} style={styles.legendRow}>
+                        <View style={[styles.legendDot, { backgroundColor: friend.color }]} />
+                        <Text style={styles.legendName} numberOfLines={1}>{friend.name}</Text>
+                        {friend.dist !== null && (
+                            <Text style={styles.legendDist}>{friend.dist}</Text>
+                        )}
+                        {friend.arrowDeg === null && (
+                            <Text style={styles.legendNoPos}>no position</Text>
+                        )}
+                    </View>
+                ))}
+            </View>
+        </View>
+    );
+}
+
+const styles = StyleSheet.create({
+    wrap: {
+        alignItems: 'center',
+        gap: 16,
+        paddingVertical: 4
+    },
+    compassOuter: {
+        borderRadius: COMPASS_SIZE / 2,
+        overflow: 'hidden'
+    },
+    legend: {
+        alignSelf: 'stretch',
+        gap: 8,
+        paddingHorizontal: 4
+    },
+    legendRow: {
+        flexDirection: 'row',
+        alignItems: 'center',
+        gap: 8
+    },
+    legendDot: {
+        width: 12,
+        height: 12,
+        borderRadius: 6,
+        flexShrink: 0
+    },
+    legendName: {
+        flex: 1,
+        fontSize: 14,
+        fontWeight: '600',
+        color: '#2e2a23'
+    },
+    legendDist: {
+        fontSize: 13,
+        color: '#7a7469'
+    },
+    legendNoPos: {
+        fontSize: 12,
+        color: '#9a8f84',
+        fontStyle: 'italic'
+    },
+    emptyWrap: {
+        paddingHorizontal: 8,
+        paddingVertical: 12
+    },
+    emptyText: {
+        fontSize: 14,
+        color: '#7a7469',
+        fontStyle: 'italic'
+    }
+});
+
diff --git a/mobile/src/features/friends/sync.ts b/mobile/src/features/friends/sync.ts
index d92459a..c27155a 100644
--- a/mobile/src/features/friends/sync.ts
+++ b/mobile/src/features/friends/sync.ts
@@ -61,12 +61,14 @@ export const subscribeToFriends = ({
     db,
     roomId,
     roomKey,
+    selfUid,
     onUpdate,
     onError
 }: {
     db: Database;
     roomId: string;
     roomKey: SymmetricKey | null;
+    selfUid: string;
     onUpdate: (friends: MobileFriendRecord[]) => void;
     onError: (error: Error) => void;
 }) => {
@@ -107,7 +109,7 @@ export const subscribeToFriends = ({
                     return friend;
                 }));
 
-                const nextFriends = resolved.filter((item): item is MobileFriendRecord => item !== null);
+                const nextFriends = resolved.filter((item): item is MobileFriendRecord => item !== null && item.id !== selfUid);
                 nextFriends.sort((a, b) => {
                     const aName = (a.name || '').toLowerCase();
                     const bName = (b.name || '').toLowerCase();
diff --git a/mobile/src/platform/firebase.ts b/mobile/src/platform/firebase.ts
index 27aa473..1a45ebf 100644
--- a/mobile/src/platform/firebase.ts
+++ b/mobile/src/platform/firebase.ts
@@ -1,5 +1,6 @@
 import { getApp, getApps, initializeApp } from 'firebase/app';
-import { getAuth, signInAnonymously } from 'firebase/auth';
+import { initializeAuth, getAuth, getReactNativePersistence, signInAnonymously } from 'firebase/auth';
+import ReactNativeAsyncStorage from '@react-native-async-storage/async-storage';
 import { getDatabase, type Database } from 'firebase/database';
 
 const firebaseConfig = {
@@ -22,8 +23,11 @@ let cachedSession: FirebaseSession | null = null;
 export const initFirebaseSession = async () => {
     if (cachedSession) return cachedSession;
 
-    const app = getApps().length > 0 ? getApp() : initializeApp(firebaseConfig);
-    const auth = getAuth(app);
+    const isNew = getApps().length === 0;
+    const app = isNew ? initializeApp(firebaseConfig) : getApp();
+    const auth = isNew
+        ? initializeAuth(app, { persistence: getReactNativePersistence(ReactNativeAsyncStorage) })
+        : getAuth(app);
     const db = getDatabase(app);
 
     const authSignInResponse = await signInAnonymously(auth);
diff --git a/mobile/src/screens/onboarding_screen.tsx b/mobile/src/screens/onboarding_screen.tsx
index 7c2070b..0fefa6c 100644
--- a/mobile/src/screens/onboarding_screen.tsx
+++ b/mobile/src/screens/onboarding_screen.tsx
@@ -1,5 +1,5 @@
 import { useState } from 'react';
-import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
+import { Image, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
 
 const PRESET_COLORS = [
     '#e53935', '#f06292', '#ab47bc', '#7986cb', '#42a5f5',
@@ -50,6 +50,8 @@ export function OnboardingScreen({
         const hash = buildRoomHash(roomId, keyB64);
 
         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.`);
 
         onStartRoom({ roomHash: hash, roomKey: key });
     };
@@ -93,8 +95,11 @@ export function OnboardingScreen({
             />
             <ScrollView contentContainerStyle={styles.container}>
                 <View style={styles.heroCard}>
-                    <View style={styles.heroOrbA} />
-                    <View style={styles.heroOrbB} />
+                    <Image
+                        source={require('../../assets/compass.png')}
+                        style={styles.heroCompass}
+                        resizeMode="contain"
+                    />
                     <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>
@@ -180,25 +185,14 @@ const styles = StyleSheet.create({
         borderWidth: 1,
         borderColor: '#c9b89d'
     },
-    heroOrbA: {
-        position: 'absolute',
-        width: 130,
-        height: 130,
-        borderRadius: 65,
-        backgroundColor: '#c8b08a',
-        right: -30,
-        top: -35,
-        opacity: 0.55
-    },
-    heroOrbB: {
+    heroCompass: {
         position: 'absolute',
-        width: 100,
-        height: 100,
-        borderRadius: 50,
-        backgroundColor: '#b38a56',
-        right: 20,
-        bottom: -45,
-        opacity: 0.22
+        width: 170,
+        height: 170,
+        right: -38,
+        top: -40,
+        opacity: 0.2,
+        transform: [{ rotate: '22deg' }]
     },
     heroEyebrow: {
         letterSpacing: 1.6,
diff --git a/mobile/src/screens/room_screen.tsx b/mobile/src/screens/room_screen.tsx
index b217828..a2b9021 100644
--- a/mobile/src/screens/room_screen.tsx
+++ b/mobile/src/screens/room_screen.tsx
@@ -86,6 +86,11 @@ 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 6bb2efd..31a0c9a 100644
--- a/mobile/src/screens/room_session_screen.tsx
+++ b/mobile/src/screens/room_session_screen.tsx
@@ -1,13 +1,16 @@
 import { useEffect, useMemo, useState } from 'react';
 import {
     ActivityIndicator,
+    Image,
     Pressable,
     ScrollView,
     StyleSheet,
     Text,
     View
 } from 'react-native';
+import AsyncStorage from '@react-native-async-storage/async-storage';
 import QRCode from 'react-native-qrcode-svg';
+import { ArrowsPanel, type ArrowFriend } from '../components/arrows_panel';
 import { formatDistance, getBearingDeg, getDistanceMeters, getRelativeBearingDeg } from '../core/bearing';
 import { parseRoomFromHash } from '../core/room';
 import type { SymmetricKey } from '../core/crypto';
@@ -50,9 +53,11 @@ export function RoomSessionScreen({
     const [headingDeg, setHeadingDeg] = useState(0);
     const [showQr, setShowQr] = useState(false);
     const [customOrder, setCustomOrder] = useState<string[]>([]);
+    const [hiddenFriendIds, setHiddenFriendIds] = useState<Set<string>>(new Set());
 
     const parsedRoom = useMemo(() => parseRoomFromHash(roomHash), [roomHash]);
     const inviteUrl = `${PUBLIC_SETTINGS.links.gitlabPagesBaseUrl}#${roomHash}`;
+    const hiddenStorageKey = parsedRoom ? `friends:hidden:${parsedRoom.roomId}` : null;
 
     const orderedFriends = useMemo(() => {
         const knownIds = customOrder.filter(id => friends.some(f => f.id === id));
@@ -63,6 +68,23 @@ export function RoomSessionScreen({
         ];
     }, [friends, customOrder]);
 
+    const arrowFriends = useMemo((): ArrowFriend[] => {
+        return orderedFriends
+            .filter(f => !hiddenFriendIds.has(f.id))
+            .map(f => {
+                const bearingDeg = position && f.position
+                    ? getBearingDeg(position, f.position)
+                    : null;
+                const arrowDeg = bearingDeg !== null
+                    ? getRelativeBearingDeg(bearingDeg, headingDeg)
+                    : null;
+                const dist = position && f.position
+                    ? formatDistance(getDistanceMeters(position, f.position))
+                    : null;
+                return { id: f.id, name: f.name || f.id, color: f.color ?? '#888888', arrowDeg, dist };
+            });
+    }, [orderedFriends, hiddenFriendIds, position, headingDeg]);
+
     const moveUp = (index: number) => {
         if (index <= 0) return;
         const ids = orderedFriends.map(f => f.id);
@@ -79,6 +101,17 @@ export function RoomSessionScreen({
         setCustomOrder(next);
     };
 
+    const toggleHidden = (id: string) => {
+        setHiddenFriendIds(prev => {
+            const next = new Set(prev);
+            if (next.has(id)) next.delete(id); else next.add(id);
+            if (hiddenStorageKey) {
+                void AsyncStorage.setItem(hiddenStorageKey, JSON.stringify([...next]));
+            }
+            return next;
+        });
+    };
+
     // Auto-connect Firebase on mount
     useEffect(() => {
         let disposed = false;
@@ -90,6 +123,18 @@ export function RoomSessionScreen({
         return () => { disposed = true; };
     }, []);
 
+    // Load persisted hidden-friend preferences
+    useEffect(() => {
+        if (!hiddenStorageKey) return;
+        AsyncStorage.getItem(hiddenStorageKey).then((raw) => {
+            if (!raw) return;
+            try {
+                const ids: string[] = JSON.parse(raw);
+                setHiddenFriendIds(new Set(ids));
+            } catch { /* ignore corrupt data */ }
+        }).catch(() => { /* ignore */ });
+    }, [hiddenStorageKey]);
+
     // Start location watching on mount
     useEffect(() => {
         let stopWatching: (() => void) | null = null;
@@ -161,6 +206,7 @@ export function RoomSessionScreen({
             db: firebaseSession.db,
             roomId,
             roomKey,
+            selfUid: firebaseSession.uid,
             onUpdate: (nextFriends) => {
                 if (!isDisposed) setFriends(nextFriends);
             },
@@ -188,7 +234,11 @@ export function RoomSessionScreen({
     return (
         <ScrollView contentContainerStyle={styles.container}>
             <View style={styles.heroCard}>
-                <View style={styles.heroOrb} />
+                <Image
+                    source={require('../../assets/compass.png')}
+                    style={styles.heroCompass}
+                    resizeMode="contain"
+                />
                 <View style={styles.headerRow}>
                     <View>
                         <Text style={styles.title}>Room Session</Text>
@@ -200,56 +250,42 @@ export function RoomSessionScreen({
                 </View>
             </View>
 
-            <View style={styles.card}>
-                <Pressable
-                    onPress={() => setShowQr((v) => !v)}
-                    style={({ pressed }) => [styles.qrToggleRow, pressed && styles.buttonPressed]}
-                >
-                    <Text style={styles.label}>Invite others</Text>
-                    <Text style={styles.qrToggleChevron}>{showQr ? '▲' : '▼'}</Text>
-                </Pressable>
-                {showQr && (
-                    <View style={styles.qrWrap}>
-                        <QRCode value={inviteUrl} size={200} />
-                        <Text style={styles.valueMuted}>Scan to join on web or mobile</Text>
-                    </View>
-                )}
+            {/* ── Arrows panel – biggest element ── */}
+            <View style={styles.arrowsCard}>
+                <View style={styles.arrowsCardHeader}>
+                    <Text style={styles.label}>Bearings</Text>
+                    {!firebaseSession && !syncError && (
+                        <View style={styles.connectingRowInline}>
+                            <ActivityIndicator size="small" color="#7a4f2a" />
+                            <Text style={styles.valueMuted}>connecting…</Text>
+                        </View>
+                    )}
+                    {locationStatus === 'denied' && <Text style={styles.errorText}>Location denied</Text>}
+                    {locationStatus === 'watching' && !position && <Text style={styles.valueMuted}>waiting for GPS…</Text>}
+                </View>
+                <ArrowsPanel friends={arrowFriends} />
+                {!!syncError && <Text style={styles.errorText}>{syncError}</Text>}
             </View>
 
+            {/* ── Friends list ── */}
             <View style={styles.card}>
                 <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>
-                )}
-                {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;
-                    const arrowDeg = bearingDeg !== null
-                        ? getRelativeBearingDeg(bearingDeg, headingDeg)
-                        : null;
-                    const dist = position && friend.position
-                        ? formatDistance(getDistanceMeters(position, friend.position))
-                        : null;
+                    const isHidden = hiddenFriendIds.has(friend.id);
                     return (
                         <View key={friend.id} style={styles.friendRow}>
-                            <View style={[styles.friendDot, { backgroundColor: friend.color ?? '#888888' }]} />
-                            <Text style={[styles.value, styles.friendName]}>{friend.name || friend.id}</Text>
-                            {dist !== null && <Text style={styles.friendDist}>{dist}</Text>}
-                            {arrowDeg !== null && (
-                                <Text style={[styles.friendArrow, { transform: [{ rotate: `${arrowDeg}deg` }] }]}>↑</Text>
-                            )}
+                            <View style={[styles.friendDot, { backgroundColor: friend.color ?? '#888888', opacity: isHidden ? 0.35 : 1 }]} />
+                            <Text style={[styles.value, styles.friendName, isHidden && styles.friendNameHidden]}>
+                                {friend.name || friend.id}
+                            </Text>
+                            <Pressable
+                                onPress={() => toggleHidden(friend.id)}
+                                style={({ pressed }) => [styles.hideButton, pressed && styles.buttonPressed]}
+                            >
+                                <Text style={styles.hideButtonText}>{isHidden ? 'show' : 'hide'}</Text>
+                            </Pressable>
                             {orderedFriends.length > 1 && (
                                 <View style={styles.moveButtons}>
                                     <Pressable
@@ -271,7 +307,26 @@ export function RoomSessionScreen({
                         </View>
                     );
                 })}
-                {!!syncError && <Text style={styles.errorText}>{syncError}</Text>}
+                {orderedFriends.length === 0 && firebaseSession && (
+                    <Text style={styles.valueMuted}>No friends in room yet.</Text>
+                )}
+            </View>
+
+            {/* ── Invite QR ── */}
+            <View style={styles.card}>
+                <Pressable
+                    onPress={() => setShowQr((v) => !v)}
+                    style={({ pressed }) => [styles.qrToggleRow, pressed && styles.buttonPressed]}
+                >
+                    <Text style={styles.label}>Invite others</Text>
+                    <Text style={styles.qrToggleChevron}>{showQr ? '▲' : '▼'}</Text>
+                </Pressable>
+                {showQr && (
+                    <View style={styles.qrWrap}>
+                        <QRCode value={inviteUrl} size={200} />
+                        <Text style={styles.valueMuted}>Scan to join on web or mobile</Text>
+                    </View>
+                )}
             </View>
         </ScrollView>
     );
@@ -291,15 +346,14 @@ const styles = StyleSheet.create({
         borderWidth: 1,
         borderColor: '#c7b79d'
     },
-    heroOrb: {
+    heroCompass: {
         position: 'absolute',
-        width: 120,
-        height: 120,
-        borderRadius: 60,
-        backgroundColor: '#b38a56',
-        right: -28,
-        top: -34,
-        opacity: 0.25
+        width: 160,
+        height: 160,
+        right: -32,
+        top: -36,
+        opacity: 0.18,
+        transform: [{ rotate: '18deg' }]
     },
     headerRow: {
         flexDirection: 'row',
@@ -323,10 +377,26 @@ const styles = StyleSheet.create({
         borderWidth: 1,
         borderColor: '#d7ccb9'
     },
-    connectingRow: {
+    arrowsCard: {
+        backgroundColor: '#e4ddd0',
+        borderRadius: 16,
+        paddingVertical: 18,
+        paddingHorizontal: 12,
+        gap: 14,
+        borderWidth: 1,
+        borderColor: '#cfc4b0',
+        minHeight: 200
+    },
+    arrowsCardHeader: {
         flexDirection: 'row',
         alignItems: 'center',
-        gap: 8
+        gap: 10,
+        paddingHorizontal: 4
+    },
+    connectingRowInline: {
+        flexDirection: 'row',
+        alignItems: 'center',
+        gap: 6
     },
     qrToggleRow: {
         flexDirection: 'row',
@@ -388,13 +458,19 @@ const styles = StyleSheet.create({
     friendName: {
         flex: 1
     },
-    friendDist: {
-        fontSize: 13,
-        color: '#7a7469'
+    friendNameHidden: {
+        opacity: 0.4
     },
-    friendArrow: {
-        fontSize: 24,
-        color: '#2e2a23'
+    hideButton: {
+        paddingHorizontal: 10,
+        paddingVertical: 4,
+        borderRadius: 6,
+        backgroundColor: '#d7ccb9'
+    },
+    hideButtonText: {
+        fontSize: 12,
+        color: '#5b4a3a',
+        fontWeight: '500'
     },
     moveButtons: {
         flexDirection: 'column',