fuzzyworld

north-star / commit

changed how friends are hidden or ordered

223a90a985dfc6822feadd8372640a5cda1a2813 · Pavel Safronov · 2026-03-19 00:22:13 -0700 · parents bb3374b77b36

changed how friends are hidden or ordered
 mobile/src/platform/heading.ts             |  22 +++-
 mobile/src/screens/room_session_screen.tsx | 185 ++++++++++++++++++-----------
 2 files changed, 132 insertions(+), 75 deletions(-)

diff --git a/mobile/src/platform/heading.ts b/mobile/src/platform/heading.ts
index 858f716..f57288d 100644
--- a/mobile/src/platform/heading.ts
+++ b/mobile/src/platform/heading.ts
@@ -1,7 +1,9 @@
 import * as Location from 'expo-location';
 
-// Lower alpha = smoother but slower to track movement. 0.15 is a good balance.
-const SMOOTH_ALPHA = 0.15;
+// Lower alpha = smoother but slower to track movement.
+const SMOOTH_ALPHA = 0.08;
+// Ignore heading updates smaller than this to suppress oscillation noise.
+const DEAD_ZONE_DEG = 2;
 
 /**
  * Watches device heading using the platform's tilt-compensated compass
@@ -20,6 +22,7 @@ export const watchHeading = (
     let sSin = 0;
     let sCos = 1;
     let initialized = false;
+    let lastReportedDeg = 0;
     let subscription: Location.LocationSubscription | null = null;
     let disposed = false;
 
@@ -31,13 +34,22 @@ export const watchHeading = (
         if (!initialized) {
             sSin = Math.sin(rad);
             sCos = Math.cos(rad);
+            lastReportedDeg = rawDeg;
             initialized = true;
-        } else {
-            sSin = SMOOTH_ALPHA * Math.sin(rad) + (1 - SMOOTH_ALPHA) * sSin;
-            sCos = SMOOTH_ALPHA * Math.cos(rad) + (1 - SMOOTH_ALPHA) * sCos;
+            onHeading(rawDeg);
+            return;
         }
 
+        sSin = SMOOTH_ALPHA * Math.sin(rad) + (1 - SMOOTH_ALPHA) * sSin;
+        sCos = SMOOTH_ALPHA * Math.cos(rad) + (1 - SMOOTH_ALPHA) * sCos;
+
         const smoothedDeg = ((Math.atan2(sSin, sCos) * 180 / Math.PI) % 360 + 360) % 360;
+
+        // Dead zone: skip tiny updates that are just sensor noise
+        const diff = Math.abs(((smoothedDeg - lastReportedDeg + 540) % 360) - 180);
+        if (diff < DEAD_ZONE_DEG) return;
+
+        lastReportedDeg = smoothedDeg;
         onHeading(smoothedDeg);
     }).then((sub) => {
         if (disposed) sub.remove();
diff --git a/mobile/src/screens/room_session_screen.tsx b/mobile/src/screens/room_session_screen.tsx
index 488c9d8..08739fb 100644
--- a/mobile/src/screens/room_session_screen.tsx
+++ b/mobile/src/screens/room_session_screen.tsx
@@ -1,7 +1,8 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
 import {
     ActivityIndicator,
     Image,
+    PanResponder,
     Pressable,
     ScrollView,
     Share,
@@ -27,6 +28,8 @@ const NORTH_FRIEND_ID = '__north__';
 const NORTH_COLOR = '#000000';
 // 100 yards in latitude degrees (~91.44 m)
 const NORTH_YARDS_LAT_OFFSET = 91.44 / 111320;
+// Approximate height of a friend row (pt) used for drag-index calculations
+const ROW_HEIGHT = 44;
 
 type RoomSessionScreenProps = {
     roomHash: string;
@@ -61,11 +64,16 @@ export function RoomSessionScreen({
     const [headingDeg, setHeadingDeg] = useState(0);
     const [headingOffset, setHeadingOffset] = useState(0);
     const [rawHeadingDeg, setRawHeadingDeg] = useState<number | null>(null);
-    const [showQr, setShowQr] = useState(false);
     const [customOrder, setCustomOrder] = useState<string[]>([]);
     const [hiddenFriendIds, setHiddenFriendIds] = useState<Set<string>>(new Set());
     const [rawMag, setRawMag] = useState<{ x: number; y: number; z: number } | null>(null);
     const [rawAccel, setRawAccel] = useState<{ x: number; y: number; z: number } | null>(null);
+    // Drag-to-reorder state
+    const [activeDragFromIndex, setActiveDragFromIndex] = useState<number | null>(null);
+    const [activeDragToIndex, setActiveDragToIndex] = useState<number | null>(null);
+    const [scrollEnabled, setScrollEnabled] = useState(true);
+    const scrollRef = useRef<ScrollView>(null);
+    const dragActiveRef = useRef<{ fromIndex: number; toIndex: number } | null>(null);
 
     const parsedRoom = useMemo(() => parseRoomFromHash(roomHash), [roomHash]);
     const inviteUrl = `${PUBLIC_SETTINGS.links.gitlabPagesBaseUrl}#${roomHash}`;
@@ -110,21 +118,59 @@ export function RoomSessionScreen({
             });
     }, [orderedFriends, hiddenFriendIds, position, headingDeg, headingOffset]);
 
-    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);
-    };
+    // Temporarily rearranged list shown while dragging
+    const displayFriends = useMemo(() => {
+        if (activeDragFromIndex === null || activeDragToIndex === null) return orderedFriends;
+        const list = [...orderedFriends];
+        const [item] = list.splice(activeDragFromIndex, 1);
+        list.splice(activeDragToIndex, 0, item);
+        return list;
+    }, [orderedFriends, activeDragFromIndex, activeDragToIndex]);
 
-    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);
-    };
+    // One PanResponder per friend, keyed by id, recreated only when order changes
+    const panResponderMap = useMemo(() => {
+        const map = new Map<string, ReturnType<typeof PanResponder.create>>();
+        orderedFriends.forEach((friend, index) => {
+            map.set(friend.id, PanResponder.create({
+                onStartShouldSetPanResponder: () => true,
+                onStartShouldSetPanResponderCapture: () => true,
+                onPanResponderGrant: () => {
+                    dragActiveRef.current = { fromIndex: index, toIndex: index };
+                    setActiveDragFromIndex(index);
+                    setActiveDragToIndex(index);
+                    setScrollEnabled(false);
+                },
+                onPanResponderMove: (_, gs) => {
+                    const toIndex = Math.max(0, Math.min(
+                        orderedFriends.length - 1,
+                        Math.round(index + gs.dy / ROW_HEIGHT)
+                    ));
+                    if (dragActiveRef.current) dragActiveRef.current.toIndex = toIndex;
+                    setActiveDragToIndex(toIndex);
+                },
+                onPanResponderRelease: () => {
+                    setScrollEnabled(true);
+                    const drag = dragActiveRef.current;
+                    dragActiveRef.current = null;
+                    setActiveDragFromIndex(null);
+                    setActiveDragToIndex(null);
+                    if (drag && drag.toIndex !== drag.fromIndex) {
+                        const ids = orderedFriends.map(f => f.id);
+                        ids.splice(drag.fromIndex, 1);
+                        ids.splice(drag.toIndex, 0, orderedFriends[drag.fromIndex].id);
+                        setCustomOrder(ids);
+                    }
+                },
+                onPanResponderTerminate: () => {
+                    setScrollEnabled(true);
+                    dragActiveRef.current = null;
+                    setActiveDragFromIndex(null);
+                    setActiveDragToIndex(null);
+                },
+            }));
+        });
+        return map;
+    }, [orderedFriends]);
 
     const toggleHidden = (id: string) => {
         setHiddenFriendIds(prev => {
@@ -256,7 +302,7 @@ export function RoomSessionScreen({
     }, [allFriends]);
 
     return (
-        <ScrollView contentContainerStyle={styles.container}>
+        <ScrollView ref={scrollRef} scrollEnabled={scrollEnabled} contentContainerStyle={styles.container}>
             <View style={styles.heroCard}>
                 <Image
                     source={require('../../assets/compass.png')}
@@ -292,42 +338,35 @@ export function RoomSessionScreen({
                     </Pressable>
                 </View>
                 <ArrowsPanel friends={arrowFriends} />
-                {/* Friend rows with controls */}
+                {/* Friend rows with drag-to-reorder and checkbox visibility */}
                 <View style={styles.friendsList}>
-                    {orderedFriends.map((friend, index) => {
+                    {displayFriends.map((friend) => {
                         const isHidden = hiddenFriendIds.has(friend.id);
                         const hasPos = !!friend.position;
+                        const isDragging = activeDragFromIndex !== null &&
+                            orderedFriends[activeDragFromIndex]?.id === friend.id;
+                        const pr = panResponderMap.get(friend.id);
                         return (
-                            <View key={friend.id} style={styles.friendRow}>
+                            <View key={friend.id} style={[styles.friendRow, isDragging && styles.friendRowDragging]}>
+                                {/* Drag handle */}
+                                <View {...pr?.panHandlers} style={styles.dragHandle}>
+                                    <View style={styles.hamburgerLine} />
+                                    <View style={styles.hamburgerLine} />
+                                    <View style={styles.hamburgerLine} />
+                                </View>
                                 <View style={[styles.friendDot, { backgroundColor: friend.color ?? '#888888', opacity: isHidden ? 0.35 : 1 }]} />
                                 <Text style={[styles.value, styles.friendName, isHidden && styles.friendNameHidden]} numberOfLines={1}>
                                     {friend.name || friend.id}
                                 </Text>
-                                {!hasPos && !isHidden && friend.id !== NORTH_FRIEND_ID && <Text style={styles.noPos}>no pos</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
-                                            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>
+                                {!hasPos && !isHidden && friend.id !== NORTH_FRIEND_ID && (
+                                    <Text style={styles.noPos}>no pos</Text>
                                 )}
+                                {/* Checkbox: checked = visible */}
+                                <Pressable onPress={() => toggleHidden(friend.id)} hitSlop={8}>
+                                    <View style={styles.checkbox}>
+                                        {!isHidden && <View style={styles.checkboxTick} />}
+                                    </View>
+                                </Pressable>
                             </View>
                         );
                     })}
@@ -488,34 +527,40 @@ const styles = StyleSheet.create({
         color: '#9a8f84',
         fontStyle: 'italic'
     },
-    hideButton: {
-        paddingHorizontal: 10,
-        paddingVertical: 4,
-        borderRadius: 6,
-        backgroundColor: '#d7ccb9'
-    },
-    hideButtonText: {
-        fontSize: 12,
-        color: '#5b4a3a',
-        fontWeight: '500'
-    },
-    moveButtons: {
-        flexDirection: 'column',
-        gap: 2
-    },
-    moveButton: {
+    // Drag handle (hamburger)
+    dragHandle: {
         paddingHorizontal: 6,
-        paddingVertical: 3,
-        borderRadius: 4,
-        backgroundColor: '#d7ccb9'
-    },
-    moveButtonDim: {
-        opacity: 0.3
+        paddingVertical: 8,
+        gap: 3,
+        justifyContent: 'center' as const,
+        alignItems: 'center' as const
+    },
+    hamburgerLine: {
+        width: 16,
+        height: 2,
+        backgroundColor: '#9a8f84',
+        borderRadius: 1
+    },
+    friendRowDragging: {
+        opacity: 0.5,
+        backgroundColor: '#d7ccb9',
+        borderRadius: 8
     },
-    moveButtonText: {
-        fontSize: 10,
-        color: '#2e2a23',
-        lineHeight: 12
+    // Checkbox
+    checkbox: {
+        width: 20,
+        height: 20,
+        borderRadius: 4,
+        borderWidth: 2,
+        borderColor: '#7a4f2a',
+        justifyContent: 'center' as const,
+        alignItems: 'center' as const
+    },
+    checkboxTick: {
+        width: 10,
+        height: 10,
+        borderRadius: 2,
+        backgroundColor: '#7a4f2a'
     },
     calibrateButton: {
         backgroundColor: '#d7ccb9',