diff --git a/app/(protected)/(tabs)/index.tsx b/app/(protected)/(tabs)/index.tsx index f071466..3d69b2a 100644 --- a/app/(protected)/(tabs)/index.tsx +++ b/app/(protected)/(tabs)/index.tsx @@ -15,6 +15,7 @@ import { InfiniteColumn } from "@/components/videocall/InfiniteColumn"; import { useSafeAreaControl } from "@/components/SafeArea"; import { DebugFAB } from "@/components/debug/DebugFAB"; import { useDebugSection } from "@/components/debug/DebugContext"; +import { useDebugEnabled } from "@/utils/debugState"; const t = createT("auth.home"); @@ -32,6 +33,7 @@ function canCall(plan: PlanType, count: number | undefined) { } export default function Index() { + const IS_DEV = useDebugEnabled(); const { theme, gs } = useTheme(); const { plan } = useActivePlan(); const [callsData, , , refetch] = useAuthFetch<{ callsToday: number }>( @@ -151,7 +153,7 @@ export default function Index() { return ( - + {IS_DEV && } diff --git a/app/(protected)/(tabs)/settings.tsx b/app/(protected)/(tabs)/settings.tsx index ea9daf8..38caa5b 100644 --- a/app/(protected)/(tabs)/settings.tsx +++ b/app/(protected)/(tabs)/settings.tsx @@ -18,7 +18,8 @@ import Constants from "expo-constants"; import * as WebBrowser from "expo-web-browser"; import { BtnText, Button } from "@/components/button"; import { useSafeAreaControl } from "@/components/SafeArea"; -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; +import { setDebugEnabled, useDebugEnabled } from "@/utils/debugState"; import { BROWSER_OPTS } from "@/lib/web/browserConfig"; const t = createT("auth.settings"); @@ -82,6 +83,28 @@ export default function SettingsScreen() { const { theme } = useTheme(); const { logout } = useAuth(); + const isDebug = useDebugEnabled(); + + const tapCount = useRef(0); + const tapTimer = useRef | null>(null); + + function handleVersionTap() { + tapCount.current += 1; + if (tapTimer.current) clearTimeout(tapTimer.current); + tapTimer.current = setTimeout(() => { + tapCount.current = 0; + }, 2000); + + if (tapCount.current >= 5) { + tapCount.current = 0; + const next = !isDebug; + setDebugEnabled(next); + Alert.alert( + next ? "Debug ON" : "Debug OFF", + next ? "Debug panel is now enabled." : "Debug panel is now disabled.", + ); + } + } const handleLogout = async () => { Alert.alert(t("logOut"), t("confirmLogOut"), [ @@ -181,14 +204,15 @@ export default function SettingsScreen() { {/* โ”€โ”€ Version โ”€โ”€ */} - + {t("version")} {version} + {isDebug ? " ๐Ÿ›" : ""} - + diff --git a/app/(protected)/settings/personaldetails.tsx b/app/(protected)/settings/personaldetails.tsx index d1d55b1..a2e578b 100644 --- a/app/(protected)/settings/personaldetails.tsx +++ b/app/(protected)/settings/personaldetails.tsx @@ -13,6 +13,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Image, Pressable, StyleSheet, Text, TextInput, View } from "react-native"; import { KeyboardAwareScrollView } from "react-native-keyboard-aware-scroll-view"; import { datePickerCallback } from "@/utils/datePickerCallback"; +import PhoneNumberInput from "@/components/PhoneNumberInput"; import { useSafeAreaControl } from "@/components/SafeArea"; import { useAuthFetch } from "@/hooks/useAuthFetch"; import { useAuth } from "@/lib/auth/AuthProvider"; @@ -328,14 +329,41 @@ export default function PersonalDetails() { keyboardType="email-address" autoComplete="email" /> - + + + Phone Number + + {!userLoading && ( + { + setPhone( + (nationalTel || tel || "").replace( + /\s+/g, + "", + ), + ); + }} + /> + )} + justifyContent: "center", }, addImageBox: { - width: "31%", + width: "30%", aspectRatio: 1, borderRadius: 16, borderWidth: 1.5, diff --git a/app/(protected)/videocall/(pages)/CallScreen.tsx b/app/(protected)/videocall/(pages)/CallScreen.tsx index 21651ea..6ae287a 100644 --- a/app/(protected)/videocall/(pages)/CallScreen.tsx +++ b/app/(protected)/videocall/(pages)/CallScreen.tsx @@ -1,5 +1,13 @@ +import { + View, + Text, + StyleSheet, + Pressable, + Animated, + PanResponder, + useWindowDimensions, +} from "react-native"; import i18n, { createT } from "@/i18n"; -import { View, Text, StyleSheet, Pressable, Animated } from "react-native"; import { RTCView } from "react-native-webrtc"; import { Ionicons, @@ -8,7 +16,8 @@ import { FontAwesome6, } from "@expo/vector-icons"; import { useSafeAreaControl } from "@/components/SafeArea"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useNavigation } from "expo-router"; const tCall = createT("videocall.call"); @@ -16,6 +25,12 @@ export const ICEBREAKERS = Array.from({ length: 12 }, (_, i) => i18n.t(`videocall.icebreakers.${i}`), ); +const PREVIEW_W = 94; +const PREVIEW_H = 154; +const MARGIN = 16; +const TOP_INSET = 80; +const BOTTOM_INSET = 120; + interface Props { remoteUrl: string | null; localUrl: string | null; @@ -23,9 +38,6 @@ interface Props { facingMode: "user" | "environment"; controlsVisible: boolean; controlsOpacity: Animated.Value; - previewBottomAnim: Animated.Value; - localPreviewAnim: Animated.ValueXY; - panHandlers: object; icebreakerVisible: boolean; icebreakerLoading: boolean; icebreakerIndex: number; @@ -51,9 +63,6 @@ export function CallScreen({ facingMode, controlsVisible, controlsOpacity, - previewBottomAnim, - localPreviewAnim, - panHandlers, icebreakerVisible, icebreakerLoading, icebreakerIndex, @@ -83,14 +92,102 @@ export function CallScreen({ }, [showBanner, likeNotifOpacity]); const { setDisableSafeArea } = useSafeAreaControl(); + const { width: W, height: H } = useWindowDimensions(); useEffect(() => { setDisableSafeArea(true); + return () => setDisableSafeArea(false); + }); + const parentNavigation = useNavigation("/(protected)"); + const navigation = useNavigation(); + + useEffect(() => { + navigation.setOptions({ gestureEnabled: false }); + parentNavigation.setOptions({ gestureEnabled: false }); return () => { - setDisableSafeArea(false); + navigation.setOptions({ gestureEnabled: true }); + parentNavigation.setOptions({ gestureEnabled: true }); }; - }); + }, [navigation, parentNavigation]); + + // Corner positions + const corners = { + topLeft: { x: MARGIN, y: TOP_INSET }, + topRight: { x: W - PREVIEW_W - MARGIN, y: TOP_INSET }, + bottomLeft: { x: MARGIN, y: H - PREVIEW_H - BOTTOM_INSET }, + bottomRight: { x: W - PREVIEW_W - MARGIN, y: H - PREVIEW_H - BOTTOM_INSET }, + }; + + const [swapped, setSwapped] = useState(false); + const swapScale = useRef(new Animated.Value(1)).current; + + function handleSwap() { + Animated.sequence([ + Animated.spring(swapScale, { + toValue: 0.88, + useNativeDriver: true, + speed: 40, + bounciness: 0, + }), + Animated.spring(swapScale, { + toValue: 1, + useNativeDriver: true, + speed: 14, + bounciness: 8, + }), + ]).start(); + setSwapped((v) => !v); + } + + const bgUrl = swapped ? localUrl : remoteUrl; + const previewUrl = swapped ? remoteUrl : localUrl; + const previewMirror = swapped ? false : facingMode === "user"; + + const pos = useRef(new Animated.ValueXY(corners.topRight)).current; + const currentPos = useRef(corners.topRight); + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + onPanResponderGrant: () => { + pos.setOffset(currentPos.current); + pos.setValue({ x: 0, y: 0 }); + }, + onPanResponderMove: Animated.event([null, { dx: pos.x, dy: pos.y }], { + useNativeDriver: false, + }), + onPanResponderRelease: () => { + pos.flattenOffset(); + const cx = (pos.x as any)._value + PREVIEW_W / 2; + const cy = (pos.y as any)._value + PREVIEW_H / 2; + + // Pick nearest corner + const nearest = Object.values({ + topLeft: corners.topLeft, + topRight: corners.topRight, + bottomLeft: corners.bottomLeft, + bottomRight: corners.bottomRight, + }).reduce((best, c) => + Math.hypot(cx - (c.x + PREVIEW_W / 2), cy - (c.y + PREVIEW_H / 2)) < + Math.hypot( + cx - (best.x + PREVIEW_W / 2), + cy - (best.y + PREVIEW_H / 2), + ) + ? c + : best, + ); + + currentPos.current = nearest; + Animated.spring(pos, { + toValue: nearest, + useNativeDriver: false, + bounciness: 6, + }).start(); + }, + }), + ).current; const likeButtonVariant = mutualLike || receivedLike ? "liked" : "success"; const likeButtonIcon = isLiked || mutualLike ? "heart" : "heart-outline"; @@ -99,13 +196,13 @@ export function CallScreen({ return ( - {remoteUrl ? ( + {bgUrl ? ( ) : ( @@ -113,6 +210,12 @@ export function CallScreen({ )} + + + + + + )} - - - - - - - - {mutualLike ? ( - {"It's a match! ๐Ÿ’ฌ"} - ) : ( - <> - You got liked! - - Like back - - - )} - - - {localUrl && ( + {previewUrl && ( true} + {...panResponder.panHandlers} > - - - + + )} @@ -273,18 +350,20 @@ const s = StyleSheet.create({ waitingText: { color: "#fff", fontSize: 18, fontWeight: "600" }, localPreviewWrapper: { position: "absolute", - right: 16, - width: 94, - height: 154, + width: PREVIEW_W, + height: PREVIEW_H, borderRadius: 16, overflow: "hidden", backgroundColor: "#222", - borderWidth: 1.5, - borderColor: "rgba(255,255,255,0.18)", + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.4, + shadowRadius: 10, + elevation: 8, }, - localPreview: { width: "100%", height: "100%", backgroundColor: "#222" }, topBar: { position: "absolute", + zIndex: 10, top: 60, left: 12, right: 12, diff --git a/app/(protected)/videocall/(pages)/SetupScreen.tsx b/app/(protected)/videocall/(pages)/SetupScreen.tsx index d68d074..2574381 100644 --- a/app/(protected)/videocall/(pages)/SetupScreen.tsx +++ b/app/(protected)/videocall/(pages)/SetupScreen.tsx @@ -1,27 +1,77 @@ -import { useState, useMemo } from "react"; +import { useState, useMemo, useRef, useEffect } from "react"; +import { + View, + Text, + StyleSheet, + Pressable, + ScrollView, + Modal, + TouchableWithoutFeedback, +} from "react-native"; +import { + Check, + ArrowRight, + Video, + Mic, + Sparkles, + ChevronDown, +} from "lucide-react-native"; +import { mediaDevices } from "react-native-webrtc"; import { createT } from "@/i18n"; -import { View, Text, StyleSheet, Pressable, ScrollView } from "react-native"; import { Ionicons, Feather } from "@expo/vector-icons"; import { useTheme } from "@/lib/theme/context"; import { Theme } from "@/lib/theme/theme"; import BackWrapper from "@/components/backwrapper"; import { BtnText, Button } from "@/components/button"; -import { ArrowRightIcon, Check } from "lucide-react-native"; -const t = createT("videocall.setup"); +type Option = { id: string; label: string; sub?: string }; -function getCameras() { - return [ - { id: "front", label: t("frontCamera") }, - { id: "back", label: t("backCamera") }, - ]; +function deviceLabel(d: MediaDeviceInfo, index: number, kind: "video" | "audio"): Option { + const facing = (d as any).facing as string | undefined; + const fallback = + kind === "video" + ? facing === "front" + ? "Front camera" + : facing === "environment" + ? "Back camera" + : `Camera ${index + 1}` + : `Microphone ${index + 1}`; + return { + id: d.deviceId, + label: d.label || fallback, + sub: + facing === "front" + ? "Front-facing" + : facing === "environment" + ? "Rear-facing" + : undefined, + }; } -function getMicrophones() { - return [ - { id: "default", label: t("builtInMic") }, - { id: "headset", label: t("headsetMic") }, - ]; + +function useMediaDevices() { + const [cameras, setCameras] = useState([]); + const [microphones, setMicrophones] = useState([]); + + useEffect(() => { + mediaDevices.enumerateDevices().then((devices) => { + const all = devices as MediaDeviceInfo[]; + const cams = all + .filter((d) => d.kind === "videoinput") + .filter((d) => !/ultra|wide|0\.5/i.test(d.label)) + .map((d, i) => deviceLabel(d, i, "video")); + const mics = all + .filter((d) => d.kind === "audioinput") + .map((d, i) => deviceLabel(d, i, "audio")); + if (cams.length) setCameras(cams); + if (mics.length) setMicrophones(mics); + }); + }, []); + + return { cameras, microphones }; } + +const t = createT("videocall.setup"); + const INTERESTS = [ "Music", "Travel", @@ -46,14 +96,21 @@ interface Props { } export function SetupScreen({ onConnect, onTestStreak }: Props) { - const CAMERAS = getCameras(); - const MICROPHONES = getMicrophones(); - const [selectedCamera, setSelectedCamera] = useState(CAMERAS[0].id); - const [selectedMic, setSelectedMic] = useState(MICROPHONES[0].id); + const { cameras, microphones } = useMediaDevices(); + const [selectedCamera, setSelectedCamera] = useState(""); + const [selectedMic, setSelectedMic] = useState(""); const [selectedInterests, setSelectedInterests] = useState([]); const { theme } = useTheme(); const s = useMemo(() => makeStyles(theme), [theme]); + // Default to first available device once loaded + useEffect(() => { + if (cameras.length && !selectedCamera) setSelectedCamera(cameras[0].id); + }, [cameras]); + useEffect(() => { + if (microphones.length && !selectedMic) setSelectedMic(microphones[0].id); + }, [microphones]); + function toggleInterest(interest: string) { setSelectedInterests((prev) => prev.includes(interest) @@ -74,71 +131,46 @@ export function SetupScreen({ onConnect, onTestStreak }: Props) { {/* Camera */} - - - - {t("camera")} - - - {CAMERAS.map((cam) => ( - setSelectedCamera(cam.id)} - > - - {cam.label} - - - ))} - - + } + label="Camera" + theme={theme} + > + + {/* Microphone */} - - - - {t("microphone")} - - - {MICROPHONES.map((mic) => ( - setSelectedMic(mic.id)} - > - - {mic.label} - - - ))} - - + } + label="Microphone" + theme={theme} + > + + {/* Interests */} - - - - - {t("interests")}{" "} - - {t("interestsMatchHint")} - - - + } + label="Interests" + sub={ + selectedInterests.length === 0 + ? "match with people like you" + : `${selectedInterests.length} selected` + } + theme={theme} + > {INTERESTS.map((interest) => { const active = selectedInterests.includes(interest); @@ -152,7 +184,8 @@ export function SetupScreen({ onConnect, onTestStreak }: Props) { )} - + {onTestStreak && ( @@ -197,73 +230,232 @@ export function SetupScreen({ onConnect, onTestStreak }: Props) { ); } +/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Field โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +function Field({ + icon, + label, + sub, + children, + theme, +}: { + icon: React.ReactNode; + label: string; + sub?: string; + children: React.ReactNode; + theme: Theme; +}) { + const s = makeStyles(theme); + return ( + + + {icon} + {label} + {sub && ยท {sub}} + + {children} + + ); +} + +/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Dropdown โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +function Dropdown({ + label, + value, + options, + onChange, + theme, +}: { + label: string; + value: string; + options: Option[]; + onChange: (id: string) => void; + theme: Theme; +}) { + const [open, setOpen] = useState(false); + const btnRef = useRef(null); + const [anchor, setAnchor] = useState({ x: 0, y: 0, w: 0 }); + const s = makeStyles(theme); + const current = options.find((o) => o.id === value); + + const openMenu = () => { + btnRef.current?.measureInWindow((x, y, w, h) => + setAnchor({ x, y: y + h + 6, w }), + ); + setOpen(true); + }; + + return ( + <> + + + + {current?.label} + + {current?.sub && ( + + {current.sub} + + )} + + + + + setOpen(false)} + > + setOpen(false)}> + + + {options.map((o) => { + const selected = o.id === value; + return ( + { + onChange(o.id); + setOpen(false); + }} + style={({ pressed }) => [ + s.menuItem, + pressed && s.menuItemPressed, + ]} + > + + {o.label} + {o.sub && ( + {o.sub} + )} + + {selected && ( + + )} + + ); + })} + + + + + + ); +} + +/* โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Styles โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ const makeStyles = (theme: Theme) => StyleSheet.create({ scroll: { paddingBottom: 48 }, - header: { marginBottom: 36 }, + header: { marginBottom: 32 }, title: { color: theme.text, fontSize: 28, fontWeight: "700", - letterSpacing: -0.5, - marginBottom: 6, + letterSpacing: -0.6, + lineHeight: 32, }, - subtitle: { color: "#777", fontSize: 15 }, - section: { marginBottom: 28 }, - sectionHeader: { + subtitle: { + color: "#9A9A9A", + fontSize: 15, + marginTop: 8, + lineHeight: 21, + }, + + field: { marginBottom: 22 }, + fieldLabel: { flexDirection: "row", alignItems: "center", - gap: 6, - marginBottom: 12, + gap: 8, + marginBottom: 10, }, - sectionLabel: { - color: "#bbb", - fontSize: 13, + fieldLabelText: { + color: "#9A9A9A", + fontSize: 12, fontWeight: "600", - letterSpacing: 0.3, + letterSpacing: 0.6, textTransform: "uppercase", }, - sectionLabelMuted: { - color: "#555", - fontWeight: "400", - textTransform: "none", + fieldLabelSub: { + color: "#6E6E6E", fontSize: 12, + fontWeight: "400", }, - pillRow: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, - pill: { + + dropdown: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingVertical: 14, paddingHorizontal: 14, - paddingVertical: 9, - borderRadius: 10, - backgroundColor: theme.dbase, + backgroundColor: theme.cardBgDeep, borderWidth: 1, - borderColor: theme.accent, + zIndex: 1, + borderColor: theme.primary, + borderRadius: 12, + }, + dropdownOpen: { borderColor: "#3A3A3A" }, + dropdownLabel: { color: theme.text, fontSize: 15, fontWeight: "500" }, + dropdownSub: { color: "#6E6E6E", fontSize: 12, marginTop: 2 }, + + menu: { + position: "absolute", + backgroundColor: theme.cardBg, + borderWidth: 1, + borderColor: "#3A3A3A", + borderRadius: 12, + padding: 6, + shadowColor: "#000", + shadowOpacity: 0.5, + shadowRadius: 14, + shadowOffset: { width: 0, height: 14 }, + elevation: 8, + }, + menuItem: { + flexDirection: "row", + alignItems: "center", + gap: 10, + paddingVertical: 10, + paddingHorizontal: 10, + borderRadius: 8, }, - pillActive: { backgroundColor: theme.primary, borderColor: theme.primary }, - pillText: { color: theme.accent, fontSize: 14, fontWeight: "500" }, - pillTextActive: { color: theme.background }, + menuItemPressed: { backgroundColor: theme.primary + "1A" }, + menuLabel: { color: theme.text, fontSize: 14, fontWeight: "500" }, + menuSub: { color: "#6E6E6E", fontSize: 12, marginTop: 2 }, + interestGrid: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, interestTag: { flexDirection: "row", alignItems: "center", - paddingHorizontal: 13, - paddingVertical: 8, - borderRadius: 20, - backgroundColor: theme.dbase, + paddingHorizontal: 14, + paddingVertical: 9, + borderRadius: 999, + backgroundColor: "transparent", borderWidth: 1, - borderColor: theme.accent, + borderColor: theme.primary, }, - interestTagActive: { backgroundColor: theme.primary, borderColor: theme.primary }, - interestText: { color: theme.accent, fontSize: 14, fontWeight: "500" }, - interestTextActive: { color: theme.background }, - cta: { - marginTop: "auto", + interestTagActive: { backgroundColor: theme.primary, - borderRadius: 16, - paddingVertical: 16, - paddingHorizontal: 24, - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - gap: 8, + borderColor: theme.primary, }, + interestText: { color: theme.text, fontSize: 14, fontWeight: "500" }, + interestTextActive: { color: "#FFFFFF" }, }); diff --git a/app/(protected)/videocall/_layout.tsx b/app/(protected)/videocall/_layout.tsx index 0793a92..6f6f0f8 100644 --- a/app/(protected)/videocall/_layout.tsx +++ b/app/(protected)/videocall/_layout.tsx @@ -3,7 +3,10 @@ import { Stack } from "expo-router"; export default function VideoCallLayout() { return ( - + ); } diff --git a/app/(protected)/videocall/index.tsx b/app/(protected)/videocall/index.tsx index 614f276..3254f47 100644 --- a/app/(protected)/videocall/index.tsx +++ b/app/(protected)/videocall/index.tsx @@ -18,6 +18,7 @@ import { useDebugActions, } from "@/components/debug/DebugContext"; import { router } from "expo-router"; +import { useDebugEnabled } from "@/utils/debugState"; registerGlobals(); @@ -29,6 +30,7 @@ type AppScreen = "setup" | "connecting" | "call" | "streak"; export default function VideoCall() { const { setDisableSafeArea } = useSafeAreaControl(); + const IS_DEV = useDebugEnabled(); useEffect(() => { setDisableSafeArea(true); @@ -41,6 +43,7 @@ export default function VideoCall() { const _rawLog = useDebugLog("VideoCall"); const log = useCallback( (msg: string) => { + if (!IS_DEV) return; _rawLog(msg); setDebugTick((t) => t + 1); }, @@ -137,6 +140,9 @@ export default function VideoCall() { const matchmakingSocketRef = useRef(null); const stopTracksRef = useRef<() => void>(() => {}); const connectingIntentRef = useRef(false); + const selectedCameraRef = useRef(""); + const selectedMicRef = useRef(""); + const selectedOutputRef = useRef(""); const [, , , _activateMatchmakingRequest] = useAuthFetch<{ type: "waiting" | "matched"; @@ -456,8 +462,12 @@ export default function VideoCall() { try { const localStream = await mediaDevices.getUserMedia({ - audio: true, - video: { frameRate: 30, facingMode: "user" }, + audio: selectedMicRef.current + ? { deviceId: { exact: selectedMicRef.current } } + : true, + video: selectedCameraRef.current + ? { deviceId: { exact: selectedCameraRef.current }, frameRate: 30 } + : { frameRate: 30, facingMode: "user" }, }); localStreamRef.current = localStream; @@ -665,7 +675,9 @@ export default function VideoCall() { }); const handleStartConnecting = useCallback( - async (_camera: string, _mic: string, _interests: string[]) => { + async (camera: string, mic: string, _interests: string[]) => { + selectedCameraRef.current = camera; + selectedMicRef.current = mic; connectingIntentRef.current = true; log("[Nav] โ†’ connecting"); setScreen("connecting"); @@ -910,7 +922,6 @@ export default function VideoCall() { }; }, []); - // Debug sections โ€” only active in dev builds (__DEV__) useDebugSection( "Matchmaking", [ @@ -945,7 +956,7 @@ export default function VideoCall() { value: deviceRef.current ? Object.keys(deviceRef.current.rtpCapabilities?.codecs ?? {}) .length + " codecs" - : "โ€“", + : "-", }, { label: "sendTransport", @@ -963,12 +974,18 @@ export default function VideoCall() { { label: "remoteUrl", value: remoteUrl ? "โœ…" : "โŒ" }, { label: "Muted", value: isMuted ? "๐Ÿ”‡" : "๐Ÿ”Š" }, { label: "Camera", value: facingMode }, - { label: "Consumers (active)", value: String(consumersRef.current.size) }, + { + label: "Consumers (active)", + value: String(consumersRef.current.size), + }, { label: "Consumers (consuming)", value: String(consumingProducerIdsRef.current.size), }, - { label: "Consumed IDs", value: String(consumedProducerIdsRef.current.size) }, + { + label: "Consumed IDs", + value: String(consumedProducerIdsRef.current.size), + }, ], [localUrl, remoteUrl, isMuted, facingMode, debugTick], 1, @@ -1027,7 +1044,7 @@ export default function VideoCall() { setScreen("streak"); }} /> - + {IS_DEV && } ); } @@ -1036,7 +1053,7 @@ export default function VideoCall() { return ( <> - + {IS_DEV && } ); } @@ -1066,7 +1083,7 @@ export default function VideoCall() { onReaction={handleReaction} onIcebreaker={handleIcebreaker} /> - + {IS_DEV && } ); } diff --git a/app/_layout.tsx b/app/_layout.tsx index 9bfefc9..70c9b05 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -69,7 +69,7 @@ function AppContent() { async function getCustomerInfo() { try { const customerInfo = await Purchases.getCustomerInfo(); - console.log("Customer Info:", customerInfo); + if (__DEV__) console.log("Customer Info:", customerInfo); } catch (error) { console.error("Error fetching customer info:", error); } @@ -78,7 +78,7 @@ function AppContent() { async function getOfferings() { try { const offerings = await Purchases.getOfferings(); - console.log("Offerings:", JSON.stringify(offerings, null, 2)); + if (__DEV__) console.log("Offerings:", JSON.stringify(offerings, null, 2)); } catch (error) { console.error("Error fetching offerings:", error); } @@ -105,7 +105,7 @@ function AppContent() { return false; }) .then((configured) => { - console.log("Purchases configured:", configured); + if (__DEV__) console.log("Purchases configured:", configured); if (!configured) { return; } diff --git a/components/PhoneNumberInput.tsx b/components/PhoneNumberInput.tsx index cc99d4a..224049e 100644 --- a/components/PhoneNumberInput.tsx +++ b/components/PhoneNumberInput.tsx @@ -1,12 +1,13 @@ import { useTheme } from "@/lib/theme/context"; import { + AsYouType, CountryCode, getExtPrefix, parseIncompletePhoneNumber, parsePhoneNumberFromString, } from "libphonenumber-js"; import { useState } from "react"; -import { Text, TextInput, View } from "react-native"; +import { Text, TextInput, View, ViewStyle } from "react-native"; import CountryPicker, { CountryCode as CC } from "react-native-country-picker-modal"; import { z } from "zod"; @@ -24,70 +25,73 @@ interface IPhoneNumberInput { tel: string, natTel: string | undefined, ) => void; + style?: ViewStyle; + initialValue?: string; } -export default function PhoneNumberInput({ sendData }: IPhoneNumberInput) { +export default function PhoneNumberInput({ + sendData, + style, + initialValue, +}: IPhoneNumberInput) { const { theme } = useTheme(); const [countryCode, setCountryCode] = useState("CH"); - const [tel, setTel] = useState(""); - const [errors, setErrors] = useState<{ - tel?: { message: string }; - password?: { message: string }; - }>({}); - - const onInputExit = (text: string) => { - console.log("Validating phone number:", text); + const [tel, setTel] = useState(() => { + if (!initialValue) return ""; + const parsed = parsePhoneNumberFromString( + initialValue, + countryCode as CountryCode, + ); + if (parsed) return parsed.formatNational(); + return new AsYouType(countryCode as CountryCode).input(initialValue); + }); + const [errors, setErrors] = useState<{ tel?: { message: string } }>({}); + function onInputExit(text: string) { const validation = schema.safeParse({ tel: text }); if (!validation.success) { - console.log("Validation errors:", validation.error.format()); - setErrors((prev) => ({ - ...prev, + setErrors({ tel: { message: - validation.error.format().tel?._errors[0] || + validation.error.format().tel?._errors[0] ?? "Invalid phone number", }, - })); + }); return; } - const prefix = getExtPrefix(countryCode as CountryCode); - const telStriped = text.startsWith("0") - ? text.startsWith("00") - ? text.substring(2) - : text.substring(1) - : text; + setErrors({}); + + const prefix = getExtPrefix(countryCode as CountryCode); + const stripped = text.startsWith("00") + ? text.substring(2) + : text.startsWith("0") + ? text.substring(1) + : text; const parsedInternational = parsePhoneNumberFromString( - telStriped, + stripped, countryCode as CountryCode, )?.formatInternational(); - const parsedNational = parsePhoneNumberFromString( - telStriped, + stripped, countryCode as CountryCode, )?.formatNational(); - console.log("National Parsed phone number:", parsedNational); - if (!parsedInternational || parsedInternational.length < 5) { - setErrors((prev) => ({ - ...prev, - tel: { message: "Invalid phone number" }, - })); + setErrors({ tel: { message: "Invalid phone number" } }); return; } sendData(countryCode as CountryCode, prefix, text, parsedNational); - }; + } return ( - <> - + - setCountryCode(country.cca2)} - /> - setTel(text)} - onBlur={() => onInputExit(tel)} - value={tel} - /> - {errors.tel && ( - - {errors.tel.message} - - )} - - + onSelect={(country) => setCountryCode(country.cca2)} + /> + + setTel(new AsYouType(countryCode as CountryCode).input(text)) + } + onBlur={() => onInputExit(tel)} + value={tel} + /> + {errors.tel && ( + {errors.tel.message} + )} + ); } diff --git a/components/debug/DebugContext.tsx b/components/debug/DebugContext.tsx index dbcc30c..ea38562 100644 --- a/components/debug/DebugContext.tsx +++ b/components/debug/DebugContext.tsx @@ -6,6 +6,7 @@ import React, { useState, useMemo, } from "react"; +import { useDebugEnabled } from "@/utils/debugState"; export type DebugRow = { label: string; value: string }; export type DebugLog = { ts: string; section: string; msg: string }; @@ -32,9 +33,6 @@ type DebugMethods = { addLog: (section: string, msg: string) => void; }; -// Split into two contexts: methods are stable (no deps), data changes with state. -// useDebugSection/useDebugActions depend only on MethodsCtx so state changes -// never trigger effect cleanup โ†’ re-register cycles. const MethodsCtx = createContext(null); const DataCtx = createContext(null); @@ -87,7 +85,6 @@ function DebugProviderInner({ children }: { children: React.ReactNode }) { ); }, []); - // Methods object is stable โ€” all callbacks have empty deps arrays const methods = useMemo( () => ({ _registerSection, @@ -128,32 +125,43 @@ export function useDebugSection( deps: unknown[], order = 0, ) { + const isDebug = useDebugEnabled(); const methods = useContext(MethodsCtx); + const allDeps = [methods, name, order, isDebug, ...deps]; + useEffect(() => { - if (!methods) return; + if (!isDebug || !methods) { + methods?._unregisterSection(name); + return; + } methods._registerSection(name, order, rows); return () => methods._unregisterSection(name); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [methods, name, order, ...deps]); + }, allDeps); } export function useDebugActions(actions: DebugAction[], deps: unknown[]) { + const isDebug = useDebugEnabled(); const methods = useContext(MethodsCtx); useEffect(() => { - if (!methods) return; + if (!isDebug || !methods) { + // Unregister all when debug is turned off + actions.forEach((a) => methods?._unregisterAction(a.key)); + return; + } actions.forEach((a) => methods._registerAction(a)); return () => actions.forEach((a) => methods._unregisterAction(a.key)); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [methods, ...deps]); + }, [methods, isDebug, ...deps]); } export function useDebugLog(section: string) { + const isDebug = useDebugEnabled(); const methods = useContext(MethodsCtx); return useCallback( (msg: string) => { - if (!__DEV__) return; + if (!isDebug) return; methods?.addLog(section, msg); }, - [methods, section], + [methods, section, isDebug], ); } diff --git a/utils/debugState.ts b/utils/debugState.ts new file mode 100644 index 0000000..cdaa7f0 --- /dev/null +++ b/utils/debugState.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +let _enabled = false; +const _listeners = new Set<(v: boolean) => void>(); + +export function isDebugEnabled() { + return _enabled; +} + +export function setDebugEnabled(v: boolean) { + _enabled = v; + _listeners.forEach((l) => l(v)); +} + +export function useDebugEnabled() { + const [val, setVal] = useState(_enabled); + useEffect(() => { + _listeners.add(setVal); + return () => { + _listeners.delete(setVal); + }; + }, []); + return val; +}