Live video on the AT Protocol

Merge branch 'natb/player-components-store' into 0.7-rc

+9941 -49
+5 -2
js/app/components/chat/chat-box.tsx
··· 26 26 } from "features/streamplace/streamplaceSlice"; 27 27 import { usePreloadEmoji } from "hooks/usePreloadEmoji"; 28 28 import { useEffect, useRef, useState } from "react"; 29 - import { Keyboard } from "react-native"; 29 + import { Keyboard, TextStyle } from "react-native"; 30 30 import { useAppDispatch, useAppSelector } from "store/hooks"; 31 31 import { ChatMessageViewHydrated, LivestreamViewHydrated } from "streamplace"; 32 32 import { Button, Form, Input, isWeb, Text, TextArea, View } from "tamagui"; ··· 58 58 isPopout, 59 59 setIsChatVisible, 60 60 isChatVisible, 61 + chatBoxStyle, 61 62 }: { 62 63 isPopout?: boolean; 63 64 setIsChatVisible?: (visible: boolean) => void; 64 65 isChatVisible?: boolean; 66 + chatBoxStyle?: TextStyle; 65 67 }) { 66 68 const [message, setMessage] = useState(""); 67 69 const [showSuggestions, setShowSuggestions] = useState(false); ··· 475 477 <View flexDirection="row" gap="$2" position="relative"> 476 478 <View flexGrow={1} flexShrink={0} position="relative"> 477 479 <TextArea 478 - borderRadius={0} 480 + //borderRadius={0} 479 481 overflow="hidden" 480 482 returnKeyType="done" 481 483 submitBehavior="blurAndSubmit" ··· 495 497 }); 496 498 } 497 499 }} 500 + style={chatBoxStyle} 498 501 onChangeText={(text) => { 499 502 if (!emojiDataLoaded) return; 500 503 const newMessage = text.replaceAll("\n", "");
+1
js/app/components/livestream/livestream.tsx
··· 372 372 <Chat 373 373 isChatVisible={isChatVisible} 374 374 setIsChatVisible={setIsChatVisible} 375 + // chatBoxStyle={{ borderRadius: 0 }} 375 376 /> 376 377 <View> 377 378 <ChatBox
+37
js/app/components/mobile/chat.tsx
··· 1 + import { View, atoms } from "@streamplace/components"; 2 + import Chat from "components/chat/chat"; 3 + import ChatBox from "components/chat/chat-box"; 4 + const { borderRadius, bottom, gap, h, layout, w, zIndex } = atoms; 5 + 6 + type ChatPanelProps = { 7 + isPlayerRatioGreater: boolean; 8 + slideKeyboard: number; 9 + }; 10 + 11 + export function ChatPanel({ 12 + isPlayerRatioGreater, 13 + slideKeyboard, 14 + }: ChatPanelProps) { 15 + return ( 16 + <View 17 + style={[ 18 + isPlayerRatioGreater 19 + ? layout.position.relative 20 + : layout.position.absolute, 21 + h.percent[40], 22 + bottom[0], 23 + zIndex[10], 24 + w.percent[100], 25 + { transform: [{ translateY: slideKeyboard }] }, 26 + ]} 27 + > 28 + <Chat isChatVisible={true} setIsChatVisible={() => true} /> 29 + <View style={[layout.flex.column, gap.all[2], { padding: 10 }]}> 30 + <ChatBox 31 + isChatVisible={true} 32 + chatBoxStyle={{ borderRadius: borderRadius.xl }} 33 + /> 34 + </View> 35 + </View> 36 + ); 37 + }
+22
js/app/components/mobile/player.tsx
··· 1 + import { 2 + LivestreamProvider, 3 + Player as PlayerInner, 4 + PlayerProps, 5 + PlayerProvider, 6 + } from "@streamplace/components"; 7 + import { MobileUi } from "./ui"; 8 + 9 + export function Player( 10 + props: Partial<PlayerProps> & { 11 + setFullscreen?: (fullscreen: boolean) => void; 12 + }, 13 + ) { 14 + return ( 15 + <LivestreamProvider src={props.src ?? ""}> 16 + <PlayerProvider defaultId={props.playerId || undefined}> 17 + <PlayerInner {...props} /> 18 + <MobileUi /> 19 + </PlayerProvider> 20 + </LivestreamProvider> 21 + ); 22 + }
+119
js/app/components/mobile/ui-state.tsx
··· 1 + import { useNavigation } from "@react-navigation/native"; 2 + import { useLivestreamStore, usePlayerStore } from "@streamplace/components"; 3 + import { createLivestreamRecord } from "features/bluesky/blueskySlice"; 4 + import useAvatars from "hooks/useAvatars"; 5 + import { useKeyboard } from "hooks/useKeyboard"; 6 + import { useOuterAndInnerDimensions } from "hooks/useOuterAndInnerDimensions"; 7 + import { useSegmentTiming } from "hooks/useSegmentTiming"; 8 + import { useEffect, useState } from "react"; 9 + import { Dimensions, Keyboard, Platform } from "react-native"; 10 + import { useAppDispatch } from "store/hooks"; 11 + 12 + export default function useMobileUiState() { 13 + const ingest = usePlayerStore((x) => x.ingestConnectionState); 14 + const profile = useLivestreamStore((x) => x.profile); 15 + const pHeight = Number(usePlayerStore((x) => x.playerHeight)) || 0; 16 + const pWidth = Number(usePlayerStore((x) => x.playerWidth)) || 0; 17 + const ingestCamera = usePlayerStore((x) => x.ingestCamera); 18 + const setIngestCamera = usePlayerStore((x) => x.setIngestCamera); 19 + const { width, height } = Dimensions.get("window"); 20 + 21 + const [title, setTitle] = useState<string | undefined>(); 22 + const [showMetrics, setShowMetrics] = useState(false); 23 + const [showCountdown, setShowCountdown] = useState(false); 24 + const [recordSubmitted, setRecordSubmitted] = useState(false); 25 + 26 + const navigation = useNavigation(); 27 + const avatars = useAvatars(profile ? [profile?.did] : []); 28 + 29 + const isPlayerRatioGreater = pWidth / pHeight > width / height; 30 + const isSelfAndNotLive = ingest === "new"; 31 + const isLive = ingest !== null && ingest !== "new"; 32 + 33 + const ingestStarting = usePlayerStore((x) => x.ingestStarting); 34 + const setIngestStarting = usePlayerStore((x) => x.setIngestStarting); 35 + 36 + const dispatch = useAppDispatch(); 37 + 38 + const { keyboardHeight } = useKeyboard(); 39 + let { outerHeight, innerHeight } = useOuterAndInnerDimensions(); 40 + let slideKeyboard = 0; 41 + if (Platform.OS === "ios" && keyboardHeight > 0) { 42 + slideKeyboard = -keyboardHeight + (outerHeight - innerHeight); 43 + } 44 + 45 + const { segmentDeltas, mean, range, connectionQuality } = useSegmentTiming(); 46 + 47 + const handleSubmit = async () => { 48 + try { 49 + if (title) { 50 + // wait ~2 sec for the thumbnail to propogate 51 + setTimeout(() => { 52 + dispatch( 53 + createLivestreamRecord({ 54 + title, 55 + customThumbnail: undefined, // thumbnailToUse || undefined, 56 + }), 57 + ), 58 + setRecordSubmitted(true); 59 + }, 3000); 60 + } 61 + } catch (error) { 62 + console.error("Error creating livestream:", error); 63 + } finally { 64 + setRecordSubmitted(false); 65 + } 66 + }; 67 + 68 + const toggleGoLive = () => { 69 + if (!ingestStarting) { 70 + // if keyboard is open, close it 71 + if (Platform.OS === "ios" && keyboardHeight > 0) { 72 + Keyboard.dismiss(); 73 + } 74 + setShowCountdown(true); 75 + setIngestStarting(true); 76 + handleSubmit(); 77 + return; 78 + } 79 + setIngestStarting(false); 80 + }; 81 + 82 + useEffect(() => { 83 + return () => { 84 + if (ingestStarting) { 85 + setIngestStarting(false); 86 + } 87 + }; 88 + }, []); 89 + 90 + const doSetIngestCamera = () => { 91 + setIngestCamera(ingestCamera === "user" ? "environment" : "user"); 92 + }; 93 + 94 + return { 95 + ingest, 96 + profile, 97 + width, 98 + height, 99 + title, 100 + setTitle, 101 + showCountdown, 102 + setShowCountdown, 103 + recordSubmitted, 104 + setRecordSubmitted, 105 + avatars, 106 + isPlayerRatioGreater, 107 + isSelfAndNotLive, 108 + isLive, 109 + ingestStarting, 110 + slideKeyboard, 111 + segmentDeltas, 112 + mean, 113 + range, 114 + connectionQuality, 115 + toggleGoLive, 116 + doSetIngestCamera, 117 + navigation, 118 + }; 119 + }
+159
js/app/components/mobile/ui.tsx
··· 1 + import { atoms, PlayerUI, Text, Toast, View } from "@streamplace/components"; 2 + import { ChevronLeft, SwitchCamera } from "lucide-react-native"; 3 + import { Image, Pressable } from "react-native"; 4 + import { ChatPanel } from "./chat"; 5 + import useMobileUiState from "./ui-state"; 6 + 7 + const { borders, colors, gap, h, layout, position, w } = atoms; 8 + // Dropdown imports 9 + 10 + export function MobileUi() { 11 + const { 12 + ingest, 13 + profile, 14 + width, 15 + height, 16 + title, 17 + setTitle, 18 + showCountdown, 19 + setShowCountdown, 20 + recordSubmitted, 21 + setRecordSubmitted, 22 + avatars, 23 + isPlayerRatioGreater, 24 + isSelfAndNotLive, 25 + isLive, 26 + ingestStarting, 27 + slideKeyboard, 28 + segmentDeltas, 29 + mean, 30 + range, 31 + connectionQuality, 32 + toggleGoLive, 33 + doSetIngestCamera, 34 + navigation, 35 + } = useMobileUiState(); 36 + 37 + return ( 38 + <> 39 + <View style={[layout.position.absolute, h.percent[100], w.percent[100]]}> 40 + <View 41 + style={[ 42 + { 43 + padding: 6.5, 44 + backgroundColor: "rgba(0, 0, 0, 0.6)", 45 + borderRadius: 8, 46 + }, 47 + layout.position.absolute, 48 + position.left[1], 49 + position.top[1], 50 + ]} 51 + > 52 + <View style={[layout.flex.row, layout.flex.center, gap.all[2]]}> 53 + <Pressable 54 + onPress={() => { 55 + navigation.canGoBack() 56 + ? navigation.goBack() 57 + : navigation.navigate("Home", { screen: "StreamList" }); 58 + }} 59 + > 60 + <ChevronLeft /> 61 + </Pressable> 62 + <Image 63 + source={ 64 + profile?.did 65 + ? { url: avatars[profile?.did]?.avatar } 66 + : require("assets/images/goose.png") 67 + } 68 + width={32} 69 + height={32} 70 + style={[ 71 + { 72 + width: 36, 73 + height: 36, 74 + borderRadius: 999, 75 + backgroundColor: "green", 76 + }, 77 + borders.width.thin, 78 + borders.color.gray[700], 79 + ]} 80 + /> 81 + <Text>{profile?.handle}</Text> 82 + </View> 83 + </View> 84 + <View 85 + style={[ 86 + { 87 + padding: 10, 88 + backgroundColor: "rgba(0, 0, 0, 0.5)", 89 + borderRadius: 8, 90 + }, 91 + layout.position.absolute, 92 + position.right[1], 93 + position.top[1], 94 + gap.all[4], 95 + ]} 96 + > 97 + {ingest === null ? ( 98 + <PlayerUI.ContextMenu /> 99 + ) : ( 100 + <Pressable onPress={doSetIngestCamera}> 101 + <SwitchCamera size={32} color={colors.gray[200]} /> 102 + </Pressable> 103 + )} 104 + </View> 105 + </View> 106 + {isLive && ( 107 + <View 108 + style={[ 109 + layout.position.absolute, 110 + position.top[14], 111 + position.left[0], 112 + position.right[0], 113 + layout.flex.column, 114 + layout.flex.center, 115 + ]} 116 + > 117 + <PlayerUI.MetricsPanel 118 + connectionQuality={connectionQuality} 119 + showMetrics={isLive || isSelfAndNotLive} 120 + segmentDeltas={segmentDeltas} 121 + mean={mean || 999} 122 + range={range || 999} 123 + /> 124 + </View> 125 + )} 126 + {isSelfAndNotLive ? ( 127 + <PlayerUI.InputPanel 128 + title={title} 129 + setTitle={setTitle} 130 + ingestStarting={ingestStarting} 131 + toggleGoLive={toggleGoLive} 132 + slideKeyboard={slideKeyboard} 133 + /> 134 + ) : ( 135 + <ChatPanel 136 + isPlayerRatioGreater={isPlayerRatioGreater} 137 + slideKeyboard={slideKeyboard} 138 + /> 139 + )} 140 + <PlayerUI.CountdownOverlay 141 + visible={showCountdown} 142 + width={width} 143 + height={height} 144 + startFrom={3} 145 + onDone={() => { 146 + setShowCountdown(false); 147 + }} 148 + /> 149 + 150 + <Toast 151 + open={recordSubmitted} 152 + onOpenChange={setRecordSubmitted} 153 + title="You're live!" 154 + description="We're notifying your followers that you just went live." 155 + duration={5} 156 + /> 157 + </> 158 + ); 159 + }
+34 -27
js/app/components/player/use-webrtc.tsx
··· 11 11 endpoint: string, 12 12 ): [MediaStream | null, boolean] { 13 13 const [mediaStream, setMediaStream] = useState<MediaStream | null>(null); 14 - const [frames, setFrames] = useState<number>(0); 15 - const [audioFrames, setAudioFrames] = useState<number>(0); 16 14 const [stuck, setStuck] = useState<boolean>(false); 17 15 18 16 const lastChange = useRef<number>(0); ··· 34 32 } 35 33 setMediaStream(event.streams[0]); 36 34 }); 37 - peerConnection.addEventListener("connectionstatechange", (ev) => { 35 + peerConnection.addEventListener("connectionstatechange", () => { 38 36 console.log("connection state change", peerConnection.connectionState); 39 37 if (peerConnection.connectionState === "closed") { 40 38 setStuck(true); ··· 43 41 return; 44 42 } 45 43 }); 46 - peerConnection.addEventListener("negotiationneeded", (ev) => { 44 + peerConnection.addEventListener("negotiationneeded", () => { 47 45 negotiateConnectionWithClientOffer(peerConnection, endpoint); 48 46 }); 49 47 48 + let lastFramesReceived = 0; 49 + let lastAudioFramesReceived = 0; 50 + 50 51 const handle = setInterval(async () => { 51 52 const stats = await peerConnection.getStats(); 52 - stats.forEach((stat, k) => { 53 + stats.forEach((stat) => { 53 54 const mediaType = stat.mediaType /* web */ ?? stat.kind; /* native */ 54 55 if (stat.type === "inbound-rtp" && mediaType === "audio") { 55 - const audioFramesReceived = stat.lastPacketReceivedTimestamp; // stat becomes inacessible after this call 56 - setAudioFrames((oldAudioFrames: number) => { 57 - if (oldAudioFrames !== audioFramesReceived) { 58 - lastChange.current = Date.now(); 59 - setStuck(false); 60 - } 61 - return audioFramesReceived; 62 - }); 56 + const audioFramesReceived = stat.lastPacketReceivedTimestamp; 57 + if (lastAudioFramesReceived !== audioFramesReceived) { 58 + lastAudioFramesReceived = audioFramesReceived; 59 + lastChange.current = Date.now(); 60 + setStuck(false); 61 + } 63 62 } 64 63 if (stat.type === "inbound-rtp" && mediaType === "video") { 65 - const framesReceived = stat.framesReceived; // stat becomes inacessible after this call 66 - setFrames((oldFrames) => { 67 - if (oldFrames !== framesReceived) { 68 - lastChange.current = Date.now(); 69 - setStuck(false); 70 - } 71 - return framesReceived; 72 - }); 64 + const framesReceived = stat.framesReceived; 65 + if (lastFramesReceived !== framesReceived) { 66 + lastFramesReceived = framesReceived; 67 + lastChange.current = Date.now(); 68 + setStuck(false); 69 + } 73 70 } 74 71 }); 75 72 if (Date.now() - lastChange.current > 2000) { ··· 196 193 197 194 export function useWebRTCIngest({ 198 195 endpoint, 199 - streamKey, 200 196 }: { 201 197 endpoint: string; 202 - streamKey?: string; 203 198 }): [MediaStream | null, (mediaStream: MediaStream | null) => void] { 204 199 const [mediaStream, setMediaStream] = useState<MediaStream | null>(null); 205 200 const ingestConnectionState = usePlayerStore((x) => x.ingestConnectionState); ··· 208 203 ); 209 204 const dispatch = useAppDispatch(); 210 205 const storedKey = useAppSelector(selectStoredKey)?.privateKey; 211 - console.log(storedKey); 206 + 207 + const [retryTime, setRetryTime] = useState<number>(0); 212 208 useEffect(() => { 213 209 if (storedKey) { 214 210 return; ··· 227 223 bundlePolicy: "max-bundle", 228 224 }); 229 225 for (const track of mediaStream.getTracks()) { 226 + console.log( 227 + "adding track", 228 + track.kind, 229 + track.label, 230 + track.enabled, 231 + track.readyState, 232 + ); 230 233 peerConnection.addTrack(track, mediaStream); 231 234 } 232 235 peerConnection.addEventListener("connectionstatechange", (ev) => { 233 236 setIngestConnectionState(peerConnection.connectionState); 234 237 console.log("connection state change", peerConnection.connectionState); 235 - if (peerConnection.connectionState !== "connected") { 236 - return; 238 + if (peerConnection.connectionState === "failed") { 239 + setRetryTime(Date.now()); 237 240 } 238 241 }); 239 242 peerConnection.addEventListener("negotiationneeded", (ev) => { 240 243 negotiateConnectionWithClientOffer(peerConnection, endpoint, storedKey); 241 244 }); 242 245 246 + peerConnection.addEventListener("track", (ev) => { 247 + console.log(ev); 248 + }); 249 + 243 250 return () => { 244 251 peerConnection.close(); 245 252 }; 246 - }, [endpoint, mediaStream, storedKey]); 253 + }, [endpoint, mediaStream, storedKey, retryTime]); 247 254 return [mediaStream, setMediaStream]; 248 255 }
+6 -1
js/app/components/player/webrtc-primitives.native.tsx
··· 1 - export { RTCPeerConnection, RTCSessionDescription } from "react-native-webrtc"; 1 + export { 2 + RTCPeerConnection, 3 + RTCSessionDescription, 4 + MediaStream as WebRTCMediaStream, 5 + mediaDevices, 6 + } from "react-native-webrtc";
+2
js/app/components/player/webrtc-primitives.tsx
··· 26 26 27 27 export const RTCPeerConnection = window.RTCPeerConnection; 28 28 export const RTCSessionDescription = window.RTCSessionDescription; 29 + export const WebRTCMediaStream = window.MediaStream; 30 + export const mediaDevices = navigator.mediaDevices; 29 31 30 32 // Export the compatibility checker for use in other components 31 33 export { checkWebRTCSupport };
+37 -17
js/app/features/bluesky/blueskySlice.tsx
··· 8 8 } from "@atproto/api"; 9 9 import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 10 10 import { bytesToMultibase, Secp256k1Keypair } from "@atproto/crypto"; 11 - import { OAuthSession } from "@atproto/oauth-client"; 11 + import { OAuthSession } from "@streamplace/atproto-oauth-client-react-native"; 12 12 import { DID_KEY, hydrate, STORED_KEY_KEY } from "features/base/baseSlice"; 13 13 import { openLoginLink } from "features/platform/platformSlice"; 14 14 import { ··· 885 885 } 886 886 } else { 887 887 // No custom thumbnail: fetch the server-side image and upload it 888 + // try thrice lel 889 + let tries = 0; 888 890 try { 889 - const thumbnailRes = await fetch( 890 - `${u.protocol}//${u.host}/api/playback/${profile.handle}/stream.png`, 891 - ); 892 - if (!thumbnailRes.ok) { 893 - throw new Error( 894 - `Failed to fetch thumbnail: ${thumbnailRes.status})`, 895 - ); 891 + for (; tries < 3; tries++) { 892 + try { 893 + console.log( 894 + `Fetching thumbnail from ${u.protocol}//${u.host}/api/playback/${profile.handle}/stream.png`, 895 + ); 896 + const thumbnailRes = await fetch( 897 + `${u.protocol}//${u.host}/api/playback/${profile.handle}/stream.png`, 898 + ); 899 + if (!thumbnailRes.ok) { 900 + throw new Error( 901 + `Failed to fetch thumbnail: ${thumbnailRes.status})`, 902 + ); 903 + } 904 + const thumbnailBlob = await thumbnailRes.blob(); 905 + console.log(thumbnailBlob); 906 + thumbnail = await uploadThumbnail( 907 + profile.handle, 908 + u, 909 + bluesky.pdsAgent, 910 + profile, 911 + thumbnailBlob, 912 + ); 913 + } catch (e) { 914 + console.warn( 915 + `Failed to fetch thumbnail, retrying (${tries + 1}/3): ${e}`, 916 + ); 917 + // Wait 1 second before retrying 918 + await new Promise((resolve) => setTimeout(resolve, 2000)); 919 + if (tries === 2) { 920 + throw new Error( 921 + `Failed to fetch thumbnail after 3 tries: ${e}`, 922 + ); 923 + } 924 + } 896 925 } 897 - const thumbnailBlob = await thumbnailRes.blob(); 898 - console.log(thumbnailBlob); 899 - thumbnail = await uploadThumbnail( 900 - profile.handle, 901 - u, 902 - bluesky.pdsAgent, 903 - profile, 904 - thumbnailBlob, 905 - ); 906 926 } catch (e) { 907 927 throw new Error(`Thumbnail upload failed ${e}`); 908 928 }
+32
js/app/hooks/useOuterAndInnerDimensions.tsx
··· 1 + import { useCallback, useState } from "react"; 2 + import { LayoutChangeEvent } from "react-native"; 3 + 4 + export function useOuterAndInnerDimensions() { 5 + const [outerDimensions, setOuterDimensions] = useState({ 6 + width: 0, 7 + height: 0, 8 + }); 9 + const [innerDimensions, setInnerDimensions] = useState({ 10 + width: 0, 11 + height: 0, 12 + }); 13 + 14 + const onOuterLayout = useCallback((event: LayoutChangeEvent) => { 15 + const { width, height } = event.nativeEvent.layout; 16 + setOuterDimensions({ width, height }); 17 + }, []); 18 + 19 + const onInnerLayout = useCallback((event: LayoutChangeEvent) => { 20 + const { width, height } = event.nativeEvent.layout; 21 + setInnerDimensions({ width, height }); 22 + }, []); 23 + 24 + return { 25 + outerWidth: outerDimensions.width, 26 + outerHeight: outerDimensions.height, 27 + innerWidth: innerDimensions.width, 28 + innerHeight: innerDimensions.height, 29 + onOuterLayout, 30 + onInnerLayout, 31 + }; 32 + }
+88
js/app/hooks/useSegmentTiming.tsx
··· 1 + import { useLivestreamStore } from "@streamplace/components"; 2 + import { useEffect, useRef, useState } from "react"; 3 + 4 + export type ConnectionQuality = "good" | "degraded" | "poor"; 5 + 6 + function getLiveConnectionQuality( 7 + timeBetweenSegments: number | null, 8 + range: number | null, 9 + numOfSegments: number = 1, 10 + ): ConnectionQuality { 11 + if (timeBetweenSegments === null || range === null) return "poor"; 12 + 13 + if (timeBetweenSegments <= 1500 && range <= (1500 * 60) / numOfSegments) { 14 + return "good"; 15 + } 16 + if (timeBetweenSegments <= 3000 && range <= (3000 * 60) / numOfSegments) { 17 + return "degraded"; 18 + } 19 + return "poor"; 20 + } 21 + 22 + export function useSegmentTiming() { 23 + const latestSegment = useLivestreamStore((x) => x.segment); 24 + const [segmentDeltas, setSegmentDeltas] = useState<number[]>([]); 25 + const prevSegmentRef = useRef<any>(); 26 + const prevTimestampRef = useRef<number | null>(null); 27 + 28 + // Dummy state to force update every second 29 + const [, setNow] = useState(Date.now()); 30 + 31 + useEffect(() => { 32 + const interval = setInterval(() => { 33 + setNow(Date.now()); 34 + }, 1000); 35 + return () => clearInterval(interval); 36 + }, []); 37 + 38 + useEffect(() => { 39 + if (latestSegment && prevSegmentRef.current !== latestSegment) { 40 + const now = Date.now(); 41 + if (prevTimestampRef.current !== null) { 42 + const delta = now - prevTimestampRef.current; 43 + // Only store the last 25 deltas 44 + setSegmentDeltas((prev) => [...prev, delta].slice(-25)); 45 + } 46 + prevTimestampRef.current = now; 47 + prevSegmentRef.current = latestSegment; 48 + } 49 + }, [latestSegment]); 50 + 51 + // The most recent time between segments 52 + const timeBetweenSegments = 53 + segmentDeltas.length > 0 54 + ? segmentDeltas[segmentDeltas.length - 1] 55 + : prevTimestampRef.current 56 + ? Date.now() - prevTimestampRef.current 57 + : null; 58 + 59 + // Calculate mean and range of deltas 60 + const mean = 61 + segmentDeltas.length > 0 62 + ? Math.round( 63 + segmentDeltas.reduce((acc, curr) => acc + curr, 0) / 64 + segmentDeltas.length, 65 + ) 66 + : null; 67 + 68 + const range = 69 + segmentDeltas.length > 0 70 + ? Math.max(...segmentDeltas) - Math.min(...segmentDeltas) 71 + : null; 72 + 73 + let to_ret = { 74 + segmentDeltas, 75 + timeBetweenSegments, 76 + mean, 77 + range, 78 + connectionQuality: "poor", 79 + }; 80 + 81 + to_ret.connectionQuality = getLiveConnectionQuality( 82 + timeBetweenSegments, 83 + range, 84 + segmentDeltas.length, 85 + ); 86 + 87 + return to_ret; 88 + }
+2
js/app/src/polyfills.native.tsx
··· 1 + import * as rnqc from "react-native-quick-crypto"; 2 + global.crypto = rnqc as any as Crypto;
+24
js/app/src/router.tsx
··· 83 83 configureReanimatedLogger, 84 84 ReanimatedLogLevel, 85 85 } from "react-native-reanimated"; 86 + import MobileGoLive from "./screens/mobile-go-live"; 87 + import MobileStream from "./screens/mobile-stream"; 86 88 store.dispatch(loadStateFromStorage()); 87 89 88 90 const Stack = createNativeStackNavigator(); ··· 115 117 Download: undefined; 116 118 PopoutChat: { user: string }; 117 119 Embed: { user: string }; 120 + MobileStream: { user: string }; 121 + MobileGoLive: undefined; 118 122 }; 119 123 120 124 declare global { ··· 149 153 Download: "download", 150 154 PopoutChat: "chat-popout/:user", 151 155 Embed: "embed/:user", 156 + MobileStream: "mobile/:user", 157 + MobileGoLive: "mobile-golive", 152 158 }, 153 159 }, 154 160 }; ··· 525 531 options={{ 526 532 drawerLabel: () => null, 527 533 drawerItemStyle: { display: "none" }, 534 + headerShown: false, 535 + }} 536 + /> 537 + <Drawer.Screen 538 + name="MobileStream" 539 + component={MobileStream} 540 + options={{ 541 + headerTitle: "Stream", 542 + drawerItemStyle: { display: "none" }, 543 + title: "Streamplace Stream", 544 + }} 545 + /> 546 + <Drawer.Screen 547 + name="MobileGoLive" 548 + component={MobileGoLive} 549 + options={{ 550 + headerTitle: "Go Live", 551 + title: "Go live", 528 552 headerShown: false, 529 553 }} 530 554 />
+23
js/app/src/screens/mobile-go-live.tsx
··· 1 + import { theme } from "@streamplace/components"; 2 + import { Player } from "components/mobile/player"; 3 + import { FullscreenProvider } from "contexts/FullscreenContext"; 4 + import { selectUserProfile } from "features/bluesky/blueskySlice"; 5 + import { useAppSelector } from "store/hooks"; 6 + import { Text } from "tamagui"; 7 + 8 + export default function MobileGoLive() { 9 + const userProfile = useAppSelector(selectUserProfile); 10 + 11 + if (!userProfile) { 12 + // If user profile is not available, redirect to login or show an error 13 + return <Text>You need to log in to go live!</Text>; 14 + } 15 + // get player 16 + return ( 17 + <theme.ThemeProvider> 18 + <FullscreenProvider> 19 + <Player ingest src={userProfile.did} name={userProfile.handle} /> 20 + </FullscreenProvider> 21 + </theme.ThemeProvider> 22 + ); 23 + }
+30
js/app/src/screens/mobile-stream.tsx
··· 1 + import { useNavigation } from "@react-navigation/native"; 2 + import { theme } from "@streamplace/components"; 3 + import { Player } from "components/mobile/player"; 4 + import { PlayerProps } from "components/player/props"; 5 + import { FullscreenProvider } from "contexts/FullscreenContext"; 6 + import { isWeb } from "tamagui"; 7 + import { queryToProps } from "./util"; 8 + 9 + export default function MobileStream({ route }) { 10 + const { user, protocol, url } = route.params; 11 + const navigation = useNavigation(); 12 + let extraProps: Partial<PlayerProps> = {}; 13 + if (isWeb) { 14 + extraProps = queryToProps(new URLSearchParams(window.location.search)); 15 + } 16 + let src = user; 17 + if (user === "stream") { 18 + src = url; 19 + } 20 + 21 + console.log(src); 22 + 23 + return ( 24 + <theme.ThemeProvider> 25 + <FullscreenProvider> 26 + <Player src={src} {...extraProps} /> 27 + </FullscreenProvider> 28 + </theme.ThemeProvider> 29 + ); 30 + }
+11 -1
js/components/package.json
··· 28 28 }, 29 29 "dependencies": { 30 30 "@atproto/api": "^0.15.7", 31 + "@atproto/crypto": "^0.4.4", 32 + "@gorhom/bottom-sheet": "^5.1.6", 33 + "@rn-primitives/dropdown-menu": "^1.2.0", 34 + "@rn-primitives/portal": "^1.3.0", 35 + "class-variance-authority": "^0.6.1", 36 + "lucide-react-native": "^0.514.0", 31 37 "react-native": "0.76.2", 32 38 "react-use-websocket": "^4.13.0", 33 39 "streamplace": "workspace:*", 34 - "zustand": "^5.0.5" 40 + "zustand": "^5.0.5", 41 + "react-native-gesture-handler": "~2.20.2", 42 + "react-native-reanimated": "~3.16.1", 43 + "react-native-safe-area-context": "4.12.0", 44 + "viem": "^2.21.40" 35 45 }, 36 46 "peerDependencies": { 37 47 "react": "*"
+193
js/components/src/components/mobile-player/fullscreen.native.tsx
··· 1 + import { useNavigation } from "@react-navigation/native"; 2 + import { VideoView } from "expo-video"; 3 + import { useEffect, useRef, useState } from "react"; 4 + import { BackHandler, Dimensions, StatusBar, StyleSheet } from "react-native"; 5 + import { useSafeAreaInsets } from "react-native-safe-area-context"; 6 + import { View } from "tamagui"; 7 + import { PlayerProtocol, useLivestreamStore, usePlayerStore } from "../.."; 8 + import Video from "./video.native"; 9 + 10 + // Standard 16:9 video aspect ratio 11 + const VIDEO_ASPECT_RATIO = 16 / 9; 12 + 13 + export function Fullscreen(props: { src: string }) { 14 + const ref = useRef<VideoView>(null); 15 + const insets = useSafeAreaInsets(); 16 + const navigation = useNavigation(); 17 + const [dimensions, setDimensions] = useState(Dimensions.get("window")); 18 + 19 + // Get state from player store 20 + const protocol = usePlayerStore((x) => x.protocol); 21 + const fullscreen = usePlayerStore((x) => x.fullscreen); 22 + const setFullscreen = usePlayerStore((x) => x.setFullscreen); 23 + const handle = useLivestreamStore((x) => x.profile?.handle); 24 + 25 + const setSrc = usePlayerStore((x) => x.setSrc); 26 + 27 + useEffect(() => { 28 + setSrc(props.src); 29 + }, [props.src]); 30 + 31 + // Re-calculate dimensions on orientation change 32 + useEffect(() => { 33 + const updateDimensions = () => { 34 + setDimensions(Dimensions.get("window")); 35 + }; 36 + 37 + const subscription = Dimensions.addEventListener( 38 + "change", 39 + updateDimensions, 40 + ); 41 + 42 + return () => { 43 + subscription.remove(); 44 + }; 45 + }, []); 46 + 47 + // Hide status bar when in fullscreen mode 48 + useEffect(() => { 49 + if (fullscreen) { 50 + StatusBar.setHidden(true); 51 + console.log("setting sidebar hidden"); 52 + 53 + // Hide the navigation header 54 + navigation.setOptions({ 55 + headerShown: false, 56 + }); 57 + 58 + // Handle hardware back button 59 + const backHandler = BackHandler.addEventListener( 60 + "hardwareBackPress", 61 + () => { 62 + setFullscreen(false); 63 + return true; 64 + }, 65 + ); 66 + 67 + return () => { 68 + backHandler.remove(); 69 + }; 70 + } else { 71 + StatusBar.setHidden(false); 72 + 73 + // Restore the navigation header 74 + navigation.setOptions({ 75 + headerShown: true, 76 + }); 77 + } 78 + 79 + return () => { 80 + StatusBar.setHidden(false); 81 + // Ensure header is restored if component unmounts 82 + navigation.setOptions({ 83 + headerShown: true, 84 + }); 85 + }; 86 + }, [fullscreen, navigation, setFullscreen]); 87 + 88 + // Handle fullscreen state changes for native video players 89 + useEffect(() => { 90 + // For WebRTC, we handle fullscreen manually via the custom implementation 91 + if (protocol === PlayerProtocol.WEBRTC) { 92 + return; 93 + } 94 + 95 + // For HLS and other protocols, sync with native fullscreen 96 + if (ref.current) { 97 + if (fullscreen) { 98 + ref.current.enterFullscreen(); 99 + } else { 100 + ref.current.exitFullscreen(); 101 + } 102 + } 103 + }, [fullscreen, protocol]); 104 + 105 + if (fullscreen && protocol === PlayerProtocol.WEBRTC) { 106 + // Determine if we're in landscape mode 107 + const isLandscape = dimensions.width > dimensions.height; 108 + 109 + // Calculate video container dimensions based on screen size and orientation 110 + let videoWidth: number; 111 + let videoHeight: number; 112 + 113 + if (isLandscape) { 114 + // In landscape, account for safe areas and use available height 115 + const availableHeight = dimensions.height - (insets.top + insets.bottom); 116 + const availableWidth = dimensions.width - (insets.left + insets.right); 117 + 118 + videoHeight = availableHeight; 119 + videoWidth = videoHeight * VIDEO_ASPECT_RATIO; 120 + 121 + // If calculated width exceeds available width, constrain and maintain aspect ratio 122 + if (videoWidth > availableWidth) { 123 + videoWidth = availableWidth; 124 + videoHeight = videoWidth / VIDEO_ASPECT_RATIO; 125 + } 126 + } else { 127 + // In portrait, account for safe areas 128 + const availableWidth = dimensions.width - (insets.left + insets.right); 129 + videoWidth = availableWidth; 130 + videoHeight = videoWidth / VIDEO_ASPECT_RATIO; 131 + } 132 + 133 + // Calculate position to center the video, accounting for safe areas 134 + const leftPosition = (dimensions.width - videoWidth) / 2; 135 + const topPosition = (dimensions.height - videoHeight) / 2; 136 + 137 + // When in custom fullscreen mode 138 + return ( 139 + <View 140 + style={[ 141 + styles.fullscreenContainer, 142 + { 143 + width: isLandscape ? dimensions.width + 40 : dimensions.width, 144 + height: dimensions.height, 145 + }, 146 + ]} 147 + > 148 + <View 149 + style={[ 150 + styles.videoContainer, 151 + { 152 + width: isLandscape ? videoWidth + 40 : videoWidth, 153 + height: videoHeight, 154 + left: leftPosition, 155 + top: topPosition, 156 + }, 157 + ]} 158 + > 159 + <Video /> 160 + </View> 161 + </View> 162 + ); 163 + } 164 + 165 + // Normal non-fullscreen mode 166 + return ( 167 + <> 168 + <Video /> 169 + </> 170 + ); 171 + } 172 + 173 + const styles = StyleSheet.create({ 174 + fullscreenContainer: { 175 + position: "absolute", 176 + top: 0, 177 + left: 0, 178 + right: 0, 179 + bottom: 0, 180 + backgroundColor: "#000", 181 + zIndex: 9999, 182 + elevation: 9999, 183 + margin: 0, 184 + padding: 0, 185 + justifyContent: "center", 186 + alignItems: "center", 187 + }, 188 + videoContainer: { 189 + position: "absolute", 190 + backgroundColor: "#111", 191 + overflow: "hidden", 192 + }, 193 + });
+79
js/components/src/components/mobile-player/fullscreen.tsx
··· 1 + import { useEffect, useRef } from "react"; 2 + import { View as RNView } from "react-native"; 3 + import { getFirstPlayerID, usePlayerStore } from "../.."; 4 + import { View } from "../../components/ui"; 5 + import Video from "./video"; 6 + 7 + export function Fullscreen(props: { src: string }) { 8 + const playerId = getFirstPlayerID(); 9 + const protocol = usePlayerStore((x) => x.protocol, playerId); 10 + const fullscreen = usePlayerStore((x) => x.fullscreen, playerId); 11 + const setFullscreen = usePlayerStore((x) => x.setFullscreen, playerId); 12 + const setSrc = usePlayerStore((x) => x.setSrc); 13 + 14 + const divRef = useRef<RNView>(null); 15 + const videoRef = useRef<HTMLVideoElement | null>(null); 16 + 17 + useEffect(() => { 18 + setSrc(props.src); 19 + }, [props.src]); 20 + 21 + useEffect(() => { 22 + if (!divRef.current) { 23 + return; 24 + } 25 + (async () => { 26 + if (fullscreen && !document.fullscreenElement) { 27 + try { 28 + const div = divRef.current as unknown as HTMLDivElement; 29 + if (typeof div.requestFullscreen === "function") { 30 + await div.requestFullscreen(); 31 + } else if (videoRef.current) { 32 + if ( 33 + typeof (videoRef.current as any).webkitEnterFullscreen === 34 + "function" 35 + ) { 36 + await (videoRef.current as any).webkitEnterFullscreen(); 37 + } else if ( 38 + typeof videoRef.current.requestFullscreen === "function" 39 + ) { 40 + await videoRef.current.requestFullscreen(); 41 + } 42 + } 43 + setFullscreen(true); 44 + } catch (e) { 45 + console.error("fullscreen failed", e.message); 46 + } 47 + } 48 + if (!fullscreen) { 49 + if (document.fullscreenElement) { 50 + try { 51 + await document.exitFullscreen(); 52 + } catch (e) { 53 + console.error("fullscreen exit failed", e.message); 54 + } 55 + } 56 + setFullscreen(false); 57 + } 58 + })(); 59 + }, [fullscreen, protocol]); 60 + 61 + useEffect(() => { 62 + const listener = () => { 63 + console.log("fullscreenchange", document.fullscreenElement); 64 + setFullscreen(!!document.fullscreenElement); 65 + }; 66 + document.body.addEventListener("fullscreenchange", listener); 67 + document.body.addEventListener("webkitfullscreenchange", listener); 68 + return () => { 69 + document.body.removeEventListener("fullscreenchange", listener); 70 + document.body.removeEventListener("webkitfullscreenchange", listener); 71 + }; 72 + }, []); 73 + 74 + return ( 75 + <View ref={divRef}> 76 + <Video /> 77 + </View> 78 + ); 79 + }
+134
js/components/src/components/mobile-player/player.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + import { flex, layout, w, zIndex } from "../../lib/theme/atoms"; 3 + import { useSegment } from "../../livestream-store"; 4 + import { 5 + PlayerStatus, 6 + PlayerStatusTracker, 7 + usePlayerStore, 8 + } from "../../player-store"; 9 + import { useStreamplaceStore } from "../../streamplace-store"; 10 + import { Text, View } from "../ui"; 11 + import { Fullscreen } from "./fullscreen"; 12 + import { PlayerProps } from "./props"; 13 + 14 + const OFFLINE_THRESHOLD = 10000; 15 + 16 + export * as PlayerUI from "./ui"; 17 + 18 + export function Player(props: Partial<PlayerProps>) { 19 + const playing = usePlayerStore((x) => x.status === PlayerStatus.PLAYING); 20 + 21 + const setOffline = usePlayerStore((x) => x.setOffline); 22 + const setIngest = usePlayerStore((x) => x.setIngestConnectionState); 23 + 24 + const clearControlsTimeout = usePlayerStore((x) => x.clearControlsTimeout); 25 + 26 + // Will call back every few seconds to send health updates 27 + usePlayerStatus(); 28 + 29 + useEffect(() => { 30 + setIngest(props.ingest ? "new" : null); 31 + }, []); 32 + 33 + if (typeof props.src !== "string") { 34 + return ( 35 + <View> 36 + <Text>No source provided 🤷</Text> 37 + </View> 38 + ); 39 + } 40 + 41 + useEffect(() => { 42 + return () => { 43 + clearControlsTimeout(); 44 + }; 45 + }, []); 46 + 47 + const segment = useSegment(); 48 + const [lastCheck, setLastCheck] = useState(0); 49 + 50 + useEffect(() => { 51 + if (playing) { 52 + setOffline(false); 53 + return; 54 + } 55 + if (!segment) { 56 + setOffline(false); 57 + return; 58 + } 59 + const startTime = Date.parse(segment.startTime); 60 + if (!startTime) { 61 + console.error("startTime is not a number", segment.startTime); 62 + return; 63 + } 64 + const timeSinceStart = Date.now() - startTime; 65 + if (timeSinceStart > OFFLINE_THRESHOLD) { 66 + setOffline(true); 67 + return; 68 + } 69 + const handle = setTimeout(() => { 70 + setLastCheck(Date.now()); 71 + }, 1000); 72 + return () => clearTimeout(handle); 73 + }, [segment, playing, lastCheck]); 74 + 75 + return ( 76 + <> 77 + <View 78 + style={[zIndex[0], flex.values[1], w.percent[100], layout.flex.center]} 79 + > 80 + <Fullscreen src={props.src}></Fullscreen> 81 + </View> 82 + </> 83 + ); 84 + } 85 + 86 + const POLL_INTERVAL = 5000; 87 + export function usePlayerStatus(): [PlayerStatus] { 88 + const playerStatus = usePlayerStore((x) => x.status); 89 + const url = useStreamplaceStore((x) => x.url); 90 + const playerEvent = usePlayerStore((x) => x.playerEvent); 91 + const [whatDoing, setWhatDoing] = useState<PlayerStatus>(PlayerStatus.START); 92 + const [whatDid, setWhatDid] = useState<PlayerStatusTracker>({}); 93 + const [doingSince, setDoingSince] = useState(Date.now()); 94 + const [lastUpdated, setLastUpdated] = useState(0); 95 + const updateWhatDid = (now: Date): PlayerStatusTracker => { 96 + const prev = whatDid[whatDoing] ?? 0; 97 + const duration = now.getTime() - doingSince; 98 + const ret = { 99 + ...whatDid, 100 + [whatDoing]: prev + duration, 101 + }; 102 + return ret; 103 + }; 104 + // callback to update the status 105 + useEffect(() => { 106 + const now = new Date(); 107 + if (playerStatus !== whatDoing) { 108 + setWhatDid(updateWhatDid(now)); 109 + setWhatDoing(playerStatus); 110 + setDoingSince(now.getTime()); 111 + } 112 + }, [playerStatus]); 113 + 114 + useEffect(() => { 115 + if (lastUpdated === 0) { 116 + return; 117 + } 118 + const now = new Date(); 119 + const fullWhatDid = updateWhatDid(now); 120 + setWhatDid({} as PlayerStatusTracker); 121 + setDoingSince(now.getTime()); 122 + playerEvent(url, now.toISOString(), "aq-played", { 123 + whatHappened: fullWhatDid, 124 + }); 125 + }, [lastUpdated]); 126 + 127 + useEffect(() => { 128 + const interval = setInterval((_) => { 129 + setLastUpdated(Date.now()); 130 + }, POLL_INTERVAL); 131 + return () => clearInterval(interval); 132 + }, []); 133 + return [whatDoing]; 134 + }
+11
js/components/src/components/mobile-player/props.tsx
··· 1 + export type PlayerProps = { 2 + name: string; 3 + playerId?: string; 4 + src: string; 5 + muted: boolean; 6 + telemetry: boolean; 7 + fullscreen: boolean; 8 + setFullscreen: (isFullscreen: boolean) => void; 9 + ingest?: boolean; 10 + embedded?: boolean; 11 + };
+56
js/components/src/components/mobile-player/shared.tsx
··· 1 + import { useMemo } from "react"; 2 + import { PlayerProtocol, useStreamplaceStore } from "../.."; 3 + 4 + const protocolSuffixes = { 5 + m3u8: PlayerProtocol.HLS, 6 + mp4: PlayerProtocol.PROGRESSIVE_MP4, 7 + webm: PlayerProtocol.PROGRESSIVE_WEBM, 8 + webrtc: PlayerProtocol.WEBRTC, 9 + }; 10 + 11 + export function srcToUrl( 12 + props: { 13 + src: string; 14 + selectedRendition?: string; 15 + }, 16 + protocol: PlayerProtocol, 17 + ): { 18 + url: string; 19 + protocol: string; 20 + } { 21 + const url = useStreamplaceStore((x) => x.url); 22 + return useMemo(() => { 23 + if (props.src.startsWith("http://") || props.src.startsWith("https://")) { 24 + const segments = props.src.split(/[./]/); 25 + const suffix = segments[segments.length - 1]; 26 + if (protocolSuffixes[suffix]) { 27 + return { 28 + url: props.src, 29 + protocol: protocolSuffixes[suffix], 30 + }; 31 + } else { 32 + throw new Error(`unknown playback protocol: ${suffix}`); 33 + } 34 + } 35 + let outUrl: string; 36 + if (protocol === PlayerProtocol.HLS) { 37 + if (props.selectedRendition === "auto") { 38 + outUrl = `${url}/api/playback/${props.src}/hls/index.m3u8`; 39 + } else { 40 + outUrl = `${url}/api/playback/${props.src}/hls/index.m3u8?rendition=${props.selectedRendition || "source"}`; 41 + } 42 + } else if (protocol === PlayerProtocol.PROGRESSIVE_MP4) { 43 + outUrl = `${url}/api/playback/${props.src}/stream.mp4`; 44 + } else if (protocol === PlayerProtocol.PROGRESSIVE_WEBM) { 45 + outUrl = `${url}/api/playback/${props.src}/stream.webm`; 46 + } else if (protocol === PlayerProtocol.WEBRTC) { 47 + outUrl = `${url}/api/playback/${props.src}/webrtc?rendition=${props.selectedRendition || "source"}`; 48 + } else { 49 + throw new Error(`unknown playback protocol: ${protocol}`); 50 + } 51 + return { 52 + protocol: protocol, 53 + url: outUrl, 54 + }; 55 + }, [props.src, props.selectedRendition, protocol, url]); 56 + }
+103
js/components/src/components/mobile-player/ui/countdown.tsx
··· 1 + import { useEffect, useRef, useState } from "react"; 2 + import Animated, { 3 + useAnimatedStyle, 4 + useSharedValue, 5 + withTiming, 6 + } from "react-native-reanimated"; 7 + 8 + type CountdownOverlayProps = { 9 + visible: boolean; 10 + width: number; 11 + height: number; 12 + startFrom?: number; 13 + onDone?: () => void; 14 + }; 15 + 16 + export function CountdownOverlay({ 17 + visible, 18 + width, 19 + height, 20 + startFrom = 3, 21 + onDone, 22 + }: CountdownOverlayProps) { 23 + const [countdown, setCountdown] = useState(startFrom); 24 + const intervalRef = useRef<NodeJS.Timeout | null>(null); 25 + 26 + // Animation values 27 + const scale = useSharedValue(1); 28 + const opacity = useSharedValue(1); 29 + 30 + // Animate and handle countdown 31 + useEffect(() => { 32 + if (visible) { 33 + setCountdown(startFrom); 34 + console.log("Countdown started from:", startFrom); 35 + 36 + // Start countdown interval 37 + intervalRef.current = setInterval(() => { 38 + console.log("Setting countdown"); 39 + setCountdown((prev) => { 40 + if (prev <= 1) { 41 + if (intervalRef.current) clearInterval(intervalRef.current); 42 + console.log("Probably done"); 43 + if (onDone) onDone(); 44 + return 0; 45 + } 46 + return prev - 1; 47 + }); 48 + }, 1000); 49 + } else { 50 + setCountdown(startFrom); 51 + if (intervalRef.current) clearInterval(intervalRef.current); 52 + } 53 + return () => { 54 + if (intervalRef.current) clearInterval(intervalRef.current); 55 + }; 56 + }, [visible, startFrom]); 57 + 58 + // Animate scale and opacity on countdown change 59 + useEffect(() => { 60 + if (visible && countdown > 0) { 61 + scale.value = 1; 62 + opacity.value = 1; 63 + scale.value = withTiming(1.5, { duration: 1000 }); 64 + opacity.value = withTiming(0, { duration: 1000 }); 65 + } 66 + }, [countdown, visible, scale, opacity]); 67 + 68 + const animatedStyle = useAnimatedStyle(() => ({ 69 + transform: [{ scale: scale.value }], 70 + opacity: opacity.value, 71 + })); 72 + 73 + if (!visible || countdown === 0) return null; 74 + 75 + return ( 76 + <Animated.View 77 + style={{ 78 + position: "absolute", 79 + top: 0, 80 + left: 0, 81 + width, 82 + height, 83 + backgroundColor: "rgba(0,0,0,0.7)", 84 + alignItems: "center", 85 + justifyContent: "center", 86 + zIndex: 1000, 87 + }} 88 + > 89 + <Animated.Text 90 + style={[ 91 + { 92 + color: "white", 93 + fontSize: 120, 94 + fontWeight: "bold", 95 + }, 96 + animatedStyle, 97 + ]} 98 + > 99 + {typeof countdown === "number" ? countdown : ""} 100 + </Animated.Text> 101 + </Animated.View> 102 + ); 103 + }
+4
js/components/src/components/mobile-player/ui/index.ts
··· 1 + export * from "./countdown"; 2 + export * from "./input"; 3 + export * from "./metrics"; 4 + export * from "./viewer-context-menu";
+85
js/components/src/components/mobile-player/ui/input.tsx
··· 1 + import { Keyboard, Pressable } from "react-native"; 2 + import * as atoms from "../../../lib/theme/atoms"; 3 + import { Input, Text, View } from "../../ui"; 4 + const { gap, h, layout, mt, p, position, px, py, sizes, w } = atoms; 5 + 6 + type InputPanelProps = { 7 + title: string | undefined; 8 + setTitle: (title: string) => void; 9 + ingestStarting: boolean; 10 + toggleGoLive: () => void; 11 + slideKeyboard: number; 12 + }; 13 + 14 + export function InputPanel({ 15 + title, 16 + setTitle, 17 + ingestStarting, 18 + toggleGoLive, 19 + slideKeyboard, 20 + }: InputPanelProps) { 21 + return ( 22 + <View 23 + style={[ 24 + layout.position.absolute, 25 + h.percent[30], 26 + position.bottom[0], 27 + w.percent[100], 28 + layout.flex.center, 29 + { transform: [{ translateY: slideKeyboard }] }, 30 + ]} 31 + > 32 + <View 33 + style={[ 34 + layout.flex.column, 35 + gap.all[2], 36 + sizes.maxWidth[80], 37 + { padding: 10 }, 38 + ]} 39 + > 40 + <View backgroundColor="rgba(64,64,64,0.8)" borderRadius={12}> 41 + <Input 42 + value={title} 43 + onChange={setTitle} 44 + placeholder="Enter stream title" 45 + onEndEditing={Keyboard.dismiss} 46 + /> 47 + </View> 48 + {ingestStarting ? ( 49 + <Text>Starting your stream...</Text> 50 + ) : ( 51 + <View style={[layout.flex.center]}> 52 + <Pressable 53 + onPress={toggleGoLive} 54 + style={[ 55 + px[4], 56 + py[2], 57 + layout.flex.row, 58 + layout.flex.center, 59 + gap.all[1], 60 + { 61 + backgroundColor: "rgba(64,64,64, 0.8)", 62 + borderRadius: 12, 63 + }, 64 + ]} 65 + > 66 + <View 67 + style={[ 68 + p[2], 69 + { 70 + backgroundColor: "rgba(256,0,0, 0.8)", 71 + borderRadius: 12, 72 + }, 73 + ]} 74 + /> 75 + <Text center>Go Live</Text> 76 + </Pressable> 77 + <Text color="muted" size="xs" style={[mt[2]]}> 78 + We'll announce that you're live on Bluesky. 79 + </Text> 80 + </View> 81 + )} 82 + </View> 83 + </View> 84 + ); 85 + }
+76
js/components/src/components/mobile-player/ui/metrics.tsx
··· 1 + import { AlertCircle, CircleCheck, CircleX } from "lucide-react-native"; 2 + import * as atoms from "../../../lib/theme/atoms"; 3 + import { Text, View } from "../../ui"; 4 + 5 + type MetricsPanelProps = { 6 + connectionQuality: "good" | "degraded" | "bad" | string; 7 + showMetrics: boolean; 8 + segmentDeltas: number[]; 9 + mean: number; 10 + range: number; 11 + }; 12 + 13 + export function MetricsPanel({ 14 + connectionQuality, 15 + showMetrics, 16 + segmentDeltas, 17 + mean, 18 + range, 19 + }: MetricsPanelProps) { 20 + let icon = <CircleX color="#d44" />; 21 + let color = "#d44"; 22 + if (connectionQuality === "good") { 23 + icon = <CircleCheck color="#4d4" />; 24 + color = "#4d4"; 25 + } else if (connectionQuality === "degraded") { 26 + icon = <AlertCircle color="#aa4" />; 27 + color = "#aa4"; 28 + } else { 29 + icon = <CircleX color="#d44" />; 30 + color = "#d44"; 31 + } 32 + 33 + return ( 34 + <View 35 + style={{ 36 + alignItems: "center", 37 + gap: 8, 38 + }} 39 + > 40 + <View 41 + style={{ 42 + flexDirection: "row", 43 + alignItems: "center", 44 + padding: 10, 45 + backgroundColor: "rgba(0, 0, 0, 0.5)", 46 + borderRadius: 8, 47 + gap: 4, 48 + }} 49 + > 50 + {icon} 51 + <Text 52 + style={[ 53 + atoms.pt[0], 54 + { 55 + color, 56 + }, 57 + ]} 58 + > 59 + {connectionQuality.toUpperCase()} 60 + </Text> 61 + </View> 62 + {showMetrics && ( 63 + <View> 64 + <Text> 65 + last Δ:{" "} 66 + {segmentDeltas.length > 0 67 + ? segmentDeltas[segmentDeltas.length - 1] 68 + : "—"} 69 + </Text> 70 + <Text>mean: {mean}</Text> 71 + <Text>range: {range}</Text> 72 + </View> 73 + )} 74 + </View> 75 + ); 76 + }
+70
js/components/src/components/mobile-player/ui/viewer-context-menu.tsx
··· 1 + import { Menu } from "lucide-react-native"; 2 + import { colors } from "../../../lib/theme"; 3 + import { useLivestreamStore } from "../../../livestream-store"; 4 + import { PlayerProtocol, usePlayerStore } from "../../../player-store/"; 5 + import { 6 + DropdownMenu, 7 + DropdownMenuCheckboxItem, 8 + DropdownMenuGroup, 9 + DropdownMenuInfo, 10 + DropdownMenuRadioGroup, 11 + DropdownMenuRadioItem, 12 + DropdownMenuTrigger, 13 + ResponsiveDropdownMenuContent, 14 + Text, 15 + } from "../../ui"; 16 + 17 + export function ContextMenu() { 18 + const quality = usePlayerStore((x) => x.selectedRendition); 19 + const setQuality = usePlayerStore((x) => x.setSelectedRendition); 20 + const qualities = useLivestreamStore((x) => x.renditions); 21 + 22 + const protocol = usePlayerStore((x) => x.protocol); 23 + const setProtocol = usePlayerStore((x) => x.setProtocol); 24 + 25 + const debugInfo = usePlayerStore((x) => x.showDebugInfo); 26 + const setShowDebugInfo = usePlayerStore((x) => x.setShowDebugInfo); 27 + 28 + const lowLatency = protocol === "webrtc"; 29 + const setLowLatency = (value: boolean) => { 30 + setProtocol(value ? PlayerProtocol.WEBRTC : PlayerProtocol.HLS); 31 + }; 32 + 33 + return ( 34 + <DropdownMenu> 35 + <DropdownMenuTrigger> 36 + <Menu size={32} color={colors.gray[200]} /> 37 + </DropdownMenuTrigger> 38 + <ResponsiveDropdownMenuContent> 39 + <DropdownMenuGroup title="Resolution"> 40 + <DropdownMenuRadioGroup value={quality} onValueChange={setQuality}> 41 + <DropdownMenuRadioItem value="source"> 42 + <Text>Source</Text> 43 + </DropdownMenuRadioItem> 44 + {qualities.map((r) => ( 45 + <DropdownMenuRadioItem value={r.name}> 46 + <Text>{r.name}</Text> 47 + </DropdownMenuRadioItem> 48 + ))} 49 + </DropdownMenuRadioGroup> 50 + </DropdownMenuGroup> 51 + <DropdownMenuGroup title="Advanced"> 52 + <DropdownMenuCheckboxItem 53 + checked={lowLatency} 54 + onCheckedChange={() => setLowLatency(!lowLatency)} 55 + > 56 + <Text>Low Latency</Text> 57 + </DropdownMenuCheckboxItem> 58 + <DropdownMenuInfo description="Lowers the delay between video and chat messages." /> 59 + <DropdownMenuCheckboxItem 60 + checked={debugInfo} 61 + onCheckedChange={() => setShowDebugInfo(!debugInfo)} 62 + > 63 + <Text>Segment Debug Info</Text> 64 + </DropdownMenuCheckboxItem> 65 + </DropdownMenuGroup> 66 + <DropdownMenuInfo description="Lowers the delay between video and chat messages." /> 67 + </ResponsiveDropdownMenuContent> 68 + </DropdownMenu> 69 + ); 70 + }
+247
js/components/src/components/mobile-player/use-webrtc.tsx
··· 1 + import { useEffect, useRef, useState } from "react"; 2 + import { usePlayerStore, useStreamKey } from "../.."; 3 + import { RTCPeerConnection, RTCSessionDescription } from "./webrtc-primitives"; 4 + 5 + export default function useWebRTC( 6 + endpoint: string, 7 + ): [MediaStream | null, boolean] { 8 + const [mediaStream, setMediaStream] = useState<MediaStream | null>(null); 9 + const [stuck, setStuck] = useState<boolean>(false); 10 + 11 + const lastChange = useRef<number>(0); 12 + 13 + useEffect(() => { 14 + const peerConnection = new RTCPeerConnection({ 15 + bundlePolicy: "max-bundle", 16 + }); 17 + peerConnection.addTransceiver("video", { 18 + direction: "recvonly", 19 + }); 20 + peerConnection.addTransceiver("audio", { 21 + direction: "recvonly", 22 + }); 23 + peerConnection.addEventListener("track", (event) => { 24 + const track = event.track; 25 + if (!track) { 26 + return; 27 + } 28 + setMediaStream(event.streams[0]); 29 + }); 30 + peerConnection.addEventListener("connectionstatechange", () => { 31 + console.log("connection state change", peerConnection.connectionState); 32 + if (peerConnection.connectionState === "closed") { 33 + setStuck(true); 34 + } 35 + if (peerConnection.connectionState !== "connected") { 36 + return; 37 + } 38 + }); 39 + peerConnection.addEventListener("negotiationneeded", () => { 40 + negotiateConnectionWithClientOffer(peerConnection, endpoint); 41 + }); 42 + 43 + let lastFramesReceived = 0; 44 + let lastAudioFramesReceived = 0; 45 + 46 + const handle = setInterval(async () => { 47 + const stats = await peerConnection.getStats(); 48 + stats.forEach((stat) => { 49 + const mediaType = stat.mediaType /* web */ ?? stat.kind; /* native */ 50 + if (stat.type === "inbound-rtp" && mediaType === "audio") { 51 + const audioFramesReceived = stat.lastPacketReceivedTimestamp; 52 + if (lastAudioFramesReceived !== audioFramesReceived) { 53 + lastAudioFramesReceived = audioFramesReceived; 54 + lastChange.current = Date.now(); 55 + setStuck(false); 56 + } 57 + } 58 + if (stat.type === "inbound-rtp" && mediaType === "video") { 59 + const framesReceived = stat.framesReceived; 60 + if (lastFramesReceived !== framesReceived) { 61 + lastFramesReceived = framesReceived; 62 + lastChange.current = Date.now(); 63 + setStuck(false); 64 + } 65 + } 66 + }); 67 + if (Date.now() - lastChange.current > 2000) { 68 + setStuck(true); 69 + } 70 + }, 200); 71 + 72 + return () => { 73 + clearInterval(handle); 74 + peerConnection.close(); 75 + }; 76 + }, [endpoint]); 77 + return [mediaStream, stuck]; 78 + } 79 + 80 + /** 81 + * Performs the actual SDP exchange. 82 + * 83 + * 1. Constructs the client's SDP offer 84 + * 2. Sends the SDP offer to the server, 85 + * 3. Awaits the server's offer. 86 + * 87 + * SDP describes what kind of media we can send and how the server and client communicate. 88 + * 89 + * https://developer.mozilla.org/en-US/docs/Glossary/SDP 90 + * https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html#name-protocol-operation 91 + */ 92 + export async function negotiateConnectionWithClientOffer( 93 + peerConnection: RTCPeerConnection, 94 + endpoint: string, 95 + bearerToken?: string, 96 + ) { 97 + /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/createOffer */ 98 + const offer = await peerConnection.createOffer({ 99 + offerToReceiveAudio: true, 100 + offerToReceiveVideo: true, 101 + }); 102 + /** https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/setLocalDescription */ 103 + await peerConnection.setLocalDescription(offer); 104 + 105 + /** Wait for ICE gathering to complete */ 106 + let ofr = await waitToCompleteICEGathering(peerConnection); 107 + if (!ofr) { 108 + throw Error("failed to gather ICE candidates for offer"); 109 + } 110 + 111 + /** 112 + * As long as the connection is open, attempt to... 113 + */ 114 + while (peerConnection.connectionState !== "closed") { 115 + try { 116 + /** 117 + * This response contains the server's SDP offer. 118 + * This specifies how the client should communicate, 119 + * and what kind of media client and server have negotiated to exchange. 120 + */ 121 + let response = await postSDPOffer(`${endpoint}`, ofr.sdp, bearerToken); 122 + if (response.status === 201) { 123 + let answerSDP = await response.text(); 124 + if ((peerConnection.connectionState as string) === "closed") { 125 + return; 126 + } 127 + await peerConnection.setRemoteDescription( 128 + new RTCSessionDescription({ type: "answer", sdp: answerSDP }), 129 + ); 130 + return response.headers.get("Location"); 131 + } else if (response.status === 405) { 132 + console.log( 133 + "Remember to update the URL passed into the WHIP or WHEP client", 134 + ); 135 + } else { 136 + const errorMessage = await response.text(); 137 + console.error(errorMessage); 138 + } 139 + } catch (e) { 140 + console.error(`posting sdp offer failed: ${e}`); 141 + } 142 + 143 + /** Limit reconnection attempts to at-most once every 5 seconds */ 144 + await new Promise((r) => setTimeout(r, 5000)); 145 + } 146 + } 147 + 148 + async function postSDPOffer( 149 + endpoint: string, 150 + data: string, 151 + bearerToken?: string, 152 + ) { 153 + return await fetch(endpoint, { 154 + method: "POST", 155 + mode: "cors", 156 + headers: { 157 + "content-type": "application/sdp", 158 + ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}), 159 + }, 160 + body: data, 161 + }); 162 + } 163 + 164 + /** 165 + * Receives an RTCPeerConnection and waits until 166 + * the connection is initialized or a timeout passes. 167 + * 168 + * https://www.ietf.org/archive/id/draft-ietf-wish-whip-01.html#section-4.1 169 + * https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/iceGatheringState 170 + * https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/icegatheringstatechange_event 171 + */ 172 + async function waitToCompleteICEGathering(peerConnection: RTCPeerConnection) { 173 + return new Promise<RTCSessionDescription | null>((resolve) => { 174 + /** Wait at most 1 second for ICE gathering. */ 175 + setTimeout(function () { 176 + if (peerConnection.connectionState === "closed") { 177 + return; 178 + } 179 + resolve(peerConnection.localDescription); 180 + }, 1000); 181 + peerConnection.addEventListener("icegatheringstatechange", (ev) => { 182 + if (peerConnection.iceGatheringState === "complete") { 183 + resolve(peerConnection.localDescription); 184 + } 185 + }); 186 + }); 187 + } 188 + 189 + export function useWebRTCIngest({ 190 + endpoint, 191 + }: { 192 + endpoint: string; 193 + }): [MediaStream | null, (mediaStream: MediaStream | null) => void] { 194 + const [mediaStream, setMediaStream] = useState<MediaStream | null>(null); 195 + const ingestConnectionState = usePlayerStore((x) => x.ingestConnectionState); 196 + const setIngestConnectionState = usePlayerStore( 197 + (x) => x.setIngestConnectionState, 198 + ); 199 + const storedKey = useStreamKey(); 200 + 201 + const [retryTime, setRetryTime] = useState<number>(0); 202 + useEffect(() => { 203 + if (!mediaStream) { 204 + return; 205 + } 206 + if (!storedKey) { 207 + return; 208 + } 209 + console.log("creating peer connection"); 210 + const peerConnection = new RTCPeerConnection({ 211 + bundlePolicy: "max-bundle", 212 + }); 213 + for (const track of mediaStream.getTracks()) { 214 + console.log( 215 + "adding track", 216 + track.kind, 217 + track.label, 218 + track.enabled, 219 + track.readyState, 220 + ); 221 + peerConnection.addTrack(track, mediaStream); 222 + } 223 + peerConnection.addEventListener("connectionstatechange", (ev) => { 224 + setIngestConnectionState(peerConnection.connectionState); 225 + console.log("connection state change", peerConnection.connectionState); 226 + if (peerConnection.connectionState === "failed") { 227 + setRetryTime(Date.now()); 228 + } 229 + }); 230 + peerConnection.addEventListener("negotiationneeded", (ev) => { 231 + negotiateConnectionWithClientOffer( 232 + peerConnection, 233 + endpoint, 234 + storedKey.streamKey?.privateKey, 235 + ); 236 + }); 237 + 238 + peerConnection.addEventListener("track", (ev) => { 239 + console.log(ev); 240 + }); 241 + 242 + return () => { 243 + peerConnection.close(); 244 + }; 245 + }, [endpoint, mediaStream, storedKey.streamKey?.privateKey, retryTime]); 246 + return [mediaStream, setMediaStream]; 247 + }
+356
js/components/src/components/mobile-player/video.native.tsx
··· 1 + import { useVideoPlayer, VideoPlayerEvents, VideoView } from "expo-video"; 2 + import { useCallback, useEffect, useRef, useState } from "react"; 3 + import { LayoutChangeEvent } from "react-native"; 4 + import { 5 + MediaStream, 6 + RTCView, 7 + RTCView as RTCViewIngest, 8 + } from "react-native-webrtc"; 9 + import { 10 + IngestMediaSource, 11 + PlayerStatus as IngestPlayerStatus, 12 + PlayerProtocol, 13 + PlayerStatus, 14 + Text, 15 + usePlayerStore as useIngestPlayerStore, 16 + usePlayerStore, 17 + useStreamplaceStore, 18 + View, 19 + } from "../.."; 20 + import { borderRadius, colors, p } from "../../lib/theme/atoms"; 21 + import { srcToUrl } from "./shared"; 22 + import useWebRTC, { useWebRTCIngest } from "./use-webrtc"; 23 + import { mediaDevices, WebRTCMediaStream } from "./webrtc-primitives.native"; 24 + 25 + // Add NativeIngestPlayer to the switch below! 26 + export default function VideoNative() { 27 + const protocol = usePlayerStore((x) => x.protocol); 28 + const ingest = usePlayerStore((x) => x.ingestConnectionState) != null; 29 + 30 + return ( 31 + <View> 32 + {ingest ? ( 33 + <NativeIngestPlayer /> 34 + ) : protocol === PlayerProtocol.WEBRTC ? ( 35 + <NativeWHEP /> 36 + ) : ( 37 + <NativeVideo /> 38 + )} 39 + </View> 40 + ); 41 + } 42 + 43 + export function NativeVideo() { 44 + const videoRef = useRef<VideoView | null>(null); 45 + const protocol = usePlayerStore((x) => x.protocol); 46 + 47 + const selectedRendition = usePlayerStore((x) => x.selectedRendition); 48 + const src = usePlayerStore((x) => x.src); 49 + const { url } = srcToUrl({ src: src, selectedRendition }, protocol); 50 + const setStatus = usePlayerStore((x) => x.setStatus); 51 + const muted = usePlayerStore((x) => x.muted); 52 + const volume = usePlayerStore((x) => x.volume); 53 + const setFullscreen = usePlayerStore((x) => x.setFullscreen); 54 + const fullscreen = usePlayerStore((x) => x.fullscreen); 55 + const playerEvent = usePlayerStore((x) => x.playerEvent); 56 + const spurl = useStreamplaceStore((x) => x.url); 57 + 58 + const setPlayerWidth = usePlayerStore((x) => x.setPlayerWidth); 59 + const setPlayerHeight = usePlayerStore((x) => x.setPlayerHeight); 60 + 61 + // State for live dimensions 62 + const [dimensions, setDimensions] = useState<{ 63 + width: number; 64 + height: number; 65 + }>({ width: 0, height: 0 }); 66 + 67 + const handleLayout = useCallback((event: LayoutChangeEvent) => { 68 + const { width, height } = event.nativeEvent.layout; 69 + setDimensions({ width, height }); 70 + setPlayerWidth(width); 71 + setPlayerHeight(height); 72 + }, []); 73 + 74 + useEffect(() => { 75 + return () => { 76 + setStatus(PlayerStatus.START); 77 + }; 78 + }, [setStatus]); 79 + 80 + const player = useVideoPlayer(url, (player) => { 81 + player.loop = true; 82 + player.muted = muted; 83 + player.play(); 84 + }); 85 + 86 + useEffect(() => { 87 + player.muted = muted; 88 + }, [muted, player]); 89 + 90 + useEffect(() => { 91 + player.volume = volume; 92 + }, [volume, player]); 93 + 94 + useEffect(() => { 95 + const subs = ( 96 + [ 97 + "playToEnd", 98 + "playbackRateChange", 99 + "playingChange", 100 + "sourceChange", 101 + "statusChange", 102 + "volumeChange", 103 + ] as (keyof VideoPlayerEvents)[] 104 + ).map((evType) => { 105 + return player.addListener(evType, (...args) => { 106 + const now = new Date(); 107 + playerEvent(spurl, now.toISOString(), evType, { args: args }); 108 + }); 109 + }); 110 + 111 + subs.push( 112 + player.addListener("playingChange", (newIsPlaying) => { 113 + if (newIsPlaying) { 114 + setStatus(PlayerStatus.PLAYING); 115 + } else { 116 + setStatus(PlayerStatus.WAITING); 117 + } 118 + }), 119 + ); 120 + 121 + return () => { 122 + for (const sub of subs) { 123 + sub.remove(); 124 + } 125 + }; 126 + }, [player, playerEvent, setStatus, spurl]); 127 + 128 + return ( 129 + <> 130 + <VideoView 131 + ref={videoRef} 132 + player={player} 133 + allowsFullscreen 134 + nativeControls={fullscreen} 135 + onFullscreenEnter={() => { 136 + setFullscreen(true); 137 + }} 138 + onFullscreenExit={() => { 139 + setFullscreen(false); 140 + }} 141 + allowsPictureInPicture 142 + onLayout={handleLayout} 143 + /> 144 + </> 145 + ); 146 + } 147 + 148 + export function NativeWHEP() { 149 + const selectedRendition = usePlayerStore((x) => x.selectedRendition); 150 + const src = usePlayerStore((x) => x.src); 151 + const { url } = srcToUrl( 152 + { src: src, selectedRendition }, 153 + PlayerProtocol.WEBRTC, 154 + ); 155 + const [stream, stuck] = useWebRTC(url); 156 + 157 + const setPlayerWidth = usePlayerStore((x) => x.setPlayerWidth); 158 + const setPlayerHeight = usePlayerStore((x) => x.setPlayerHeight); 159 + 160 + // PiP support: wire up videoRef (no direct ref for RTCView) 161 + const setVideoRef = usePlayerStore((x) => x.setVideoRef); 162 + 163 + // State for live dimensions 164 + const [dimensions, setDimensions] = useState<{ 165 + width: number; 166 + height: number; 167 + }>({ width: 0, height: 0 }); 168 + 169 + const handleLayout = useCallback((event: LayoutChangeEvent) => { 170 + const { width, height } = event.nativeEvent.layout; 171 + setDimensions({ width, height }); 172 + setPlayerWidth(width); 173 + setPlayerHeight(height); 174 + }, []); 175 + 176 + const setStatus = usePlayerStore((x) => x.setStatus); 177 + const muted = usePlayerStore((x) => x.muted); 178 + const volume = usePlayerStore((x) => x.volume); 179 + 180 + useEffect(() => { 181 + if (stuck) { 182 + setStatus(PlayerStatus.STALLED); 183 + } else { 184 + setStatus(PlayerStatus.PLAYING); 185 + } 186 + }, [stuck, setStatus]); 187 + 188 + const mediaStream = stream as unknown as MediaStream; 189 + 190 + useEffect(() => { 191 + if (!mediaStream) { 192 + setStatus(PlayerStatus.WAITING); 193 + return; 194 + } 195 + setStatus(PlayerStatus.PLAYING); 196 + }, [mediaStream, setStatus]); 197 + 198 + useEffect(() => { 199 + if (!mediaStream) { 200 + return; 201 + } 202 + mediaStream.getTracks().forEach((track) => { 203 + if (track.kind === "audio") { 204 + track._setVolume(muted ? 0 : volume); 205 + } 206 + }); 207 + }, [mediaStream, muted, volume]); 208 + 209 + // Keep the playerStore videoRef in sync for PiP (if possible) 210 + useEffect(() => { 211 + if (typeof setVideoRef === "function") { 212 + setVideoRef(null); // No direct ref for RTCView, but keep API consistent 213 + } 214 + }, [setVideoRef]); 215 + 216 + if (!mediaStream) { 217 + return <View></View>; 218 + } 219 + 220 + return ( 221 + <> 222 + <RTCView 223 + mirror={false} 224 + objectFit={"contain"} 225 + streamURL={mediaStream.toURL()} 226 + onLayout={handleLayout} 227 + style={{ 228 + minWidth: "100%", 229 + minHeight: "100%", 230 + flex: 1, 231 + }} 232 + /> 233 + </> 234 + ); 235 + } 236 + 237 + export function NativeIngestPlayer() { 238 + const ingestStarting = useIngestPlayerStore((x) => x.ingestStarting); 239 + const ingestMediaSource = useIngestPlayerStore((x) => x.ingestMediaSource); 240 + const ingestAutoStart = useIngestPlayerStore((x) => x.ingestAutoStart); 241 + const setStatus = useIngestPlayerStore((x) => x.setStatus); 242 + const setVideoRef = usePlayerStore((x) => x.setVideoRef); 243 + 244 + const [error, setError] = useState<Error | null>(null); 245 + 246 + const ingestCamera = useIngestPlayerStore((x) => x.ingestCamera); 247 + 248 + useEffect(() => { 249 + setStatus(IngestPlayerStatus.PLAYING); 250 + }, [setStatus]); 251 + 252 + useEffect(() => { 253 + if (typeof setVideoRef === "function") { 254 + setVideoRef(null); 255 + } 256 + }, [setVideoRef]); 257 + 258 + const url = useStreamplaceStore((x) => x.url); 259 + const [lms, setLocalMediaStream] = useState<WebRTCMediaStream | null>(null); 260 + const [, setRemoteMediaStream] = useWebRTCIngest({ 261 + endpoint: `${url}/api/ingest/webrtc`, 262 + }); 263 + 264 + // Use lms directly as localMediaStream 265 + const localMediaStream = lms; 266 + 267 + useEffect(() => { 268 + if (ingestMediaSource === IngestMediaSource.DISPLAY) { 269 + mediaDevices 270 + .getDisplayMedia() 271 + .then((stream: WebRTCMediaStream) => { 272 + console.log("display media", stream); 273 + setLocalMediaStream(stream); 274 + }) 275 + .catch((e: any) => { 276 + console.log("error getting display media", e); 277 + console.error("error getting display media", e); 278 + }); 279 + } else { 280 + mediaDevices 281 + .getUserMedia({ 282 + audio: { 283 + // deviceId: "audio-1", 284 + // echoCancellation: true, 285 + // autoGainControl: true, 286 + // noiseSuppression: true, 287 + // latency: false, 288 + // channelCount: false, 289 + }, 290 + video: { 291 + facingMode: ingestCamera, 292 + deviceId: "video-1", 293 + width: { min: 200, ideal: 1080, max: 2160 }, 294 + height: { min: 200, ideal: 1920, max: 3840 }, 295 + }, 296 + }) 297 + .then((stream: WebRTCMediaStream) => { 298 + setLocalMediaStream(stream); 299 + }) 300 + .catch((e: any) => { 301 + console.error("error getting user media", e); 302 + setError( 303 + new Error( 304 + "We could not access your camera or microphone. Please check your permissions.", 305 + ), 306 + ); 307 + }); 308 + } 309 + }, [ingestMediaSource, ingestCamera]); 310 + 311 + useEffect(() => { 312 + if (!ingestStarting && !ingestAutoStart) { 313 + setRemoteMediaStream(null); 314 + return; 315 + } 316 + if (!localMediaStream) { 317 + return; 318 + } 319 + console.log("setting remote media stream", localMediaStream); 320 + // @ts-expect-error: WebRTCMediaStream may not have all MediaStream properties, but is compatible for our use 321 + setRemoteMediaStream(localMediaStream); 322 + }, [localMediaStream, ingestStarting, ingestAutoStart, setRemoteMediaStream]); 323 + 324 + if (!localMediaStream) { 325 + return null; 326 + } 327 + 328 + if (error) { 329 + return ( 330 + <View 331 + backgroundColor={colors.destructive[900]} 332 + style={[p[4], { borderRadius: borderRadius.md }]} 333 + > 334 + <View> 335 + <Text>Error encountered!</Text> 336 + </View> 337 + <Text>{error.message}</Text> 338 + </View> 339 + ); 340 + } 341 + 342 + return ( 343 + <RTCViewIngest 344 + mirror={ingestCamera !== "environment"} 345 + objectFit={"cover"} 346 + streamURL={localMediaStream.toURL()} 347 + zOrder={0} 348 + // width is set to 5000 to ensure it fills the screen 349 + style={{ 350 + minWidth: "100%", 351 + minHeight: "100%", 352 + flex: 1, 353 + }} 354 + /> 355 + ); 356 + }
+557
js/components/src/components/mobile-player/video.tsx
··· 1 + import Hls from "hls.js"; 2 + import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; 3 + import { 4 + IngestMediaSource, 5 + PlayerProtocol, 6 + PlayerStatus, 7 + usePlayerStore, 8 + useStreamplaceStore, 9 + } from "../.."; 10 + import { borderRadius, colors, mt, p } from "../../lib/theme/atoms"; 11 + import { Text, View } from "../ui/index"; 12 + import { srcToUrl } from "./shared"; 13 + import useWebRTC, { useWebRTCIngest } from "./use-webrtc"; 14 + import { 15 + logWebRTCDiagnostics, 16 + useWebRTCDiagnostics, 17 + } from "./webrtc-diagnostics"; 18 + import { checkWebRTCSupport } from "./webrtc-primitives"; 19 + 20 + function assignVideoRef( 21 + ref: 22 + | React.MutableRefObject<HTMLVideoElement | null> 23 + | ((instance: HTMLVideoElement | null) => void) 24 + | null 25 + | undefined, 26 + instance: HTMLVideoElement | null, 27 + ) { 28 + if (!ref) return; 29 + if (typeof ref === "function") ref(instance); 30 + else ref.current = instance; 31 + } 32 + 33 + type VideoProps = { 34 + url: string; 35 + videoRef?: React.RefObject<HTMLVideoElement>; 36 + }; 37 + 38 + function useVideoDimensions(videoRef: React.RefObject<HTMLVideoElement>) { 39 + const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); 40 + 41 + useEffect(() => { 42 + if (!videoRef.current) return; 43 + 44 + function updateSize() { 45 + setDimensions({ 46 + width: videoRef.current?.videoWidth || 0, 47 + height: videoRef.current?.videoHeight || 0, 48 + }); 49 + } 50 + 51 + updateSize(); 52 + 53 + const observer = new window.ResizeObserver(updateSize); 54 + observer.observe(videoRef.current); 55 + 56 + videoRef.current.addEventListener("loadedmetadata", updateSize); 57 + videoRef.current.addEventListener("resize", updateSize); 58 + 59 + return () => { 60 + observer.disconnect(); 61 + videoRef.current?.removeEventListener("loadedmetadata", updateSize); 62 + videoRef.current?.removeEventListener("resize", updateSize); 63 + }; 64 + }, [videoRef, videoRef.current]); 65 + 66 + return dimensions; 67 + } 68 + 69 + export default function WebVideo() { 70 + const inProto = usePlayerStore((x) => x.protocol); 71 + const isIngesting = usePlayerStore((x) => x.ingestConnectionState !== null); 72 + const selectedRendition = usePlayerStore((x) => x.selectedRendition); 73 + const src = usePlayerStore((x) => x.src); 74 + const setPlayerWidth = usePlayerStore((x) => x.setPlayerWidth); 75 + const setPlayerHeight = usePlayerStore((x) => x.setPlayerHeight); 76 + const { url, protocol } = srcToUrl({ src: src, selectedRendition }, inProto); 77 + 78 + const videoRef = useRef<HTMLVideoElement | null>(null); 79 + const dimensions = useVideoDimensions(videoRef); 80 + 81 + useEffect(() => { 82 + if (videoRef.current) { 83 + setPlayerWidth(dimensions.width); 84 + setPlayerHeight(dimensions.height); 85 + } 86 + }, [dimensions, setPlayerWidth, setPlayerHeight]); 87 + 88 + const playerProps = { url, videoRef }; 89 + 90 + return ( 91 + <> 92 + {isIngesting ? ( 93 + <WebcamIngestPlayer {...playerProps} /> 94 + ) : protocol === PlayerProtocol.PROGRESSIVE_MP4 ? ( 95 + <ProgressiveMP4Player {...playerProps} /> 96 + ) : protocol === PlayerProtocol.PROGRESSIVE_WEBM ? ( 97 + <ProgressiveWebMPlayer {...playerProps} /> 98 + ) : protocol === PlayerProtocol.HLS ? ( 99 + <HLSPlayer {...playerProps} /> 100 + ) : protocol === PlayerProtocol.WEBRTC ? ( 101 + <WebRTCPlayer {...playerProps} /> 102 + ) : ( 103 + (() => { 104 + throw new Error(`unknown playback protocol ${inProto}`); 105 + })() 106 + )} 107 + </> 108 + ); 109 + } 110 + 111 + const updateEvents = { 112 + playing: true, 113 + waiting: true, 114 + stalled: true, 115 + pause: true, 116 + suspend: true, 117 + mute: true, 118 + }; 119 + 120 + const VideoElement = forwardRef< 121 + HTMLVideoElement, 122 + VideoProps & { videoRef?: React.RefObject<HTMLVideoElement> } 123 + >((props, ref) => { 124 + const x = usePlayerStore((x) => x); 125 + const url = useStreamplaceStore((x) => x.url); 126 + const playerEvent = usePlayerStore((x) => x.playerEvent); 127 + const setMuted = usePlayerStore((x) => x.setMuted); 128 + const setMuteWasForced = usePlayerStore((x) => x.setMuteWasForced); 129 + const muted = usePlayerStore((x) => x.muted); 130 + const ingest = usePlayerStore((x) => x.ingestConnectionState !== null); 131 + const volume = usePlayerStore((x) => x.volume); 132 + const setStatus = usePlayerStore((x) => x.setStatus); 133 + const setUserInteraction = usePlayerStore((x) => x.setUserInteraction); 134 + const setVideoRef = usePlayerStore((x) => x.setVideoRef); 135 + 136 + const event = (evType) => (e) => { 137 + console.log(evType); 138 + const now = new Date(); 139 + if (updateEvents[evType]) { 140 + x.setStatus(evType); 141 + } 142 + console.log("Sending", evType, "status to", url); 143 + playerEvent(url, now.toISOString(), evType, {}); 144 + }; 145 + const [firstAttempt, setFirstAttempt] = useState(true); 146 + 147 + const localVideoRef = props.videoRef ?? useRef<HTMLVideoElement | null>(null); 148 + 149 + const canPlayThrough = (e) => { 150 + event("canplaythrough")(e); 151 + if (firstAttempt && localVideoRef.current) { 152 + setFirstAttempt(false); 153 + localVideoRef.current.play().catch((err) => { 154 + if (err.name === "NotAllowedError") { 155 + if (localVideoRef.current) { 156 + setMuted(true); 157 + localVideoRef.current.muted = true; 158 + localVideoRef.current 159 + .play() 160 + .then(() => { 161 + console.warn("Browser forced video to start muted"); 162 + setMuteWasForced(true); 163 + }) 164 + .catch((err) => { 165 + console.error("error playing video", err); 166 + }); 167 + } 168 + } 169 + }); 170 + } 171 + }; 172 + 173 + useEffect(() => { 174 + return () => { 175 + setStatus(PlayerStatus.START); 176 + }; 177 + }, []); 178 + 179 + useEffect(() => { 180 + if (localVideoRef.current) { 181 + localVideoRef.current.volume = volume; 182 + console.log("Setting volume to", volume); 183 + } 184 + }, [volume]); 185 + 186 + useEffect(() => { 187 + console.log(localVideoRef.current?.width, localVideoRef.current?.height); 188 + setVideoRef(localVideoRef); 189 + }, [setVideoRef, localVideoRef]); 190 + 191 + const handleVideoRef = (videoElement: HTMLVideoElement | null) => { 192 + if (typeof ref === "function") { 193 + ref(videoElement); 194 + } else if (ref) { 195 + (ref as React.MutableRefObject<HTMLVideoElement | null>).current = 196 + videoElement; 197 + } 198 + // if (localVideoRef && typeof localVideoRef !== "function") { 199 + // localVideoRef.current = videoElement; 200 + // } 201 + }; 202 + 203 + return ( 204 + <video 205 + autoPlay={true} 206 + playsInline={true} 207 + ref={handleVideoRef} 208 + controls={false} 209 + src={ingest ? undefined : props.url} 210 + muted={muted} 211 + crossOrigin="anonymous" 212 + onMouseMove={setUserInteraction} 213 + onClick={setUserInteraction} 214 + onAbort={event("abort")} 215 + onCanPlay={event("canplay")} 216 + onCanPlayThrough={canPlayThrough} 217 + onEmptied={event("emptied")} 218 + onEncrypted={event("encrypted")} 219 + onEnded={event("ended")} 220 + onError={event("error")} 221 + onLoadedData={event("loadeddata")} 222 + onLoadedMetadata={event("loadedmetadata")} 223 + onLoadStart={event("loadstart")} 224 + onPause={event("pause")} 225 + onPlay={event("play")} 226 + onPlaying={event("playing")} 227 + onRateChange={event("ratechange")} 228 + onSeeked={event("seeked")} 229 + onSeeking={event("seeking")} 230 + onStalled={event("stalled")} 231 + onSuspend={event("suspend")} 232 + onVolumeChange={event("volumechange")} 233 + onWaiting={event("waiting")} 234 + style={{ 235 + objectFit: "contain", 236 + backgroundColor: "transparent", 237 + width: "100%", 238 + height: "100%", 239 + transform: ingest ? "scaleX(-1)" : undefined, 240 + }} 241 + /> 242 + ); 243 + }); 244 + 245 + export function ProgressiveMP4Player(props: VideoProps) { 246 + return <VideoElement {...props} />; 247 + } 248 + 249 + export function ProgressiveWebMPlayer(props: VideoProps) { 250 + return <VideoElement {...props} />; 251 + } 252 + 253 + export function HLSPlayer(props: VideoProps) { 254 + const localRef = useRef<HTMLVideoElement | null>(null); 255 + 256 + useEffect(() => { 257 + if (!localRef.current) { 258 + return; 259 + } 260 + if (Hls.isSupported()) { 261 + var hls = new Hls({ maxAudioFramesDrift: 20 }); 262 + hls.loadSource(props.url); 263 + try { 264 + hls.attachMedia(localRef.current); 265 + } catch (e) { 266 + console.error("error on attachMedia"); 267 + hls.stopLoad(); 268 + return; 269 + } 270 + hls.on(Hls.Events.MANIFEST_PARSED, () => { 271 + if (!localRef.current) { 272 + return; 273 + } 274 + localRef.current.play(); 275 + }); 276 + return () => { 277 + hls.stopLoad(); 278 + }; 279 + } else if (localRef.current.canPlayType("application/vnd.apple.mpegurl")) { 280 + localRef.current.src = props.url; 281 + localRef.current.addEventListener("canplay", () => { 282 + if (!localRef.current) { 283 + return; 284 + } 285 + localRef.current.play(); 286 + }); 287 + } 288 + }, [props.url]); 289 + return <VideoElement {...props} ref={localRef} />; 290 + } 291 + 292 + export function WebRTCPlayer( 293 + props: VideoProps & { videoRef?: React.RefObject<HTMLVideoElement> }, 294 + ) { 295 + const [webrtcError, setWebrtcError] = useState<string | null>(null); 296 + const setStatus = usePlayerStore((x) => x.setStatus); 297 + const setProtocol = usePlayerStore((x) => x.setProtocol); 298 + const diagnostics = useWebRTCDiagnostics(); 299 + // Check WebRTC compatibility on component mount 300 + useEffect(() => { 301 + try { 302 + checkWebRTCSupport(); 303 + console.log("WebRTC Player - Browser compatibility check passed"); 304 + logWebRTCDiagnostics(); 305 + } catch (error) { 306 + console.error("WebRTC Player - Compatibility error:", error.message); 307 + setWebrtcError(error.message); 308 + setStatus(PlayerStatus.START); 309 + return; 310 + } 311 + }, []); 312 + 313 + // Monitor diagnostics for errors 314 + useEffect(() => { 315 + if (!diagnostics.browserSupport && diagnostics.errors.length > 0) { 316 + setWebrtcError(diagnostics.errors.join(", ")); 317 + } 318 + }, [diagnostics]); 319 + 320 + if (!diagnostics.done) return <></>; 321 + 322 + if (webrtcError) { 323 + setProtocol(PlayerProtocol.HLS); 324 + return ( 325 + <View backgroundColor="#111"> 326 + <View> 327 + <View> 328 + <Text>WebRTC Not Supported</Text> 329 + </View> 330 + <Text>{webrtcError}</Text> 331 + {diagnostics.errors.length > 0 && ( 332 + <View> 333 + <Text>Technical Details:</Text> 334 + {diagnostics.errors.map((error, index) => ( 335 + <Text key={index}>• {error}</Text> 336 + ))} 337 + </View> 338 + )} 339 + <Text> 340 + • To use WebRTC, you may need to disable any blocking extensions or 341 + update your browser. 342 + </Text> 343 + <Text style={[mt[2]]}>Switching to HLS...</Text> 344 + </View> 345 + </View> 346 + ); 347 + } 348 + return <WebRTCPlayerInner url={props.url} videoRef={props.videoRef} />; 349 + } 350 + 351 + export function WebRTCPlayerInner({ 352 + videoRef, 353 + url, 354 + width, 355 + height, 356 + }: { 357 + videoRef?: React.RefObject<HTMLVideoElement>; 358 + url: string; 359 + width?: string | number; 360 + height?: string | number; 361 + }) { 362 + const [connectionStatus, setConnectionStatus] = 363 + useState<string>("initializing"); 364 + 365 + const status = usePlayerStore((x) => x.status); 366 + const setStatus = usePlayerStore((x) => x.setStatus); 367 + 368 + const playerEvent = usePlayerStore((x) => x.playerEvent); 369 + const spurl = useStreamplaceStore((x) => x.url); 370 + 371 + const [mediaStream, stuck] = useWebRTC(url); 372 + 373 + useEffect(() => { 374 + if (stuck) { 375 + setConnectionStatus("connection-failed"); 376 + } else if (mediaStream) { 377 + setConnectionStatus("connected"); 378 + } else { 379 + setConnectionStatus("connecting"); 380 + } 381 + }, [url, mediaStream, stuck, status]); 382 + 383 + useEffect(() => { 384 + if (stuck && status === PlayerStatus.PLAYING) { 385 + setStatus(PlayerStatus.STALLED); 386 + } 387 + if (!stuck && mediaStream) { 388 + setStatus(PlayerStatus.PLAYING); 389 + } 390 + }, [stuck, status, mediaStream]); 391 + 392 + useEffect(() => { 393 + if (!mediaStream) { 394 + return; 395 + } 396 + const evt = (evType) => (e) => { 397 + console.log("webrtc event", evType); 398 + playerEvent(spurl, new Date().toISOString(), evType, {}); 399 + }; 400 + const active = evt("active"); 401 + const inactive = evt("inactive"); 402 + const ended = evt("ended"); 403 + const mute = evt("mute"); 404 + const unmute = evt("playing"); 405 + 406 + mediaStream.addEventListener("active", active); 407 + mediaStream.addEventListener("inactive", inactive); 408 + mediaStream.addEventListener("ended", ended); 409 + for (const track of mediaStream.getTracks()) { 410 + track.addEventListener("ended", ended); 411 + track.addEventListener("mute", mute); 412 + track.addEventListener("unmute", unmute); 413 + } 414 + return () => { 415 + for (const track of mediaStream.getTracks()) { 416 + track.removeEventListener("ended", ended); 417 + track.removeEventListener("mute", mute); 418 + track.removeEventListener("unmute", unmute); 419 + } 420 + mediaStream.removeEventListener("active", active); 421 + mediaStream.removeEventListener("inactive", inactive); 422 + mediaStream.removeEventListener("ended", ended); 423 + }; 424 + }, [mediaStream]); 425 + 426 + useEffect(() => { 427 + if (!videoRef || !videoRef.current) { 428 + return; 429 + } 430 + videoRef.current.srcObject = mediaStream; 431 + }, [mediaStream]); 432 + 433 + if (!mediaStream) { 434 + return ( 435 + <View 436 + backgroundColor="#111" 437 + style={{ minWidth: "100%", minHeight: "100%" }} 438 + > 439 + <View 440 + backgroundColor={colors.primary[800]} 441 + style={{ borderRadius: borderRadius.md }} 442 + > 443 + <View> 444 + <Text>Connecting...</Text> 445 + </View> 446 + <Text>Establishing WebRTC connection ({connectionStatus})</Text> 447 + </View> 448 + </View> 449 + ); 450 + } 451 + return <VideoElement url={url} ref={videoRef} />; 452 + } 453 + 454 + export function WebcamIngestPlayer(props: VideoProps) { 455 + const ingestStarting = usePlayerStore((x) => x.ingestStarting); 456 + const ingestMediaSource = usePlayerStore((x) => x.ingestMediaSource); 457 + const ingestAutoStart = usePlayerStore((x) => x.ingestAutoStart); 458 + 459 + const [error, setError] = useState<Error | null>(null); 460 + 461 + let streamKey = null; 462 + 463 + const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>( 464 + null, 465 + ); 466 + const handleRef = useCallback((node: HTMLVideoElement | null) => { 467 + if (node) { 468 + setVideoElement(node); 469 + } 470 + }, []); 471 + 472 + const url = useStreamplaceStore((x) => x.url); 473 + const [localMediaStream, setLocalMediaStream] = useState<MediaStream | null>( 474 + null, 475 + ); 476 + // we assign a stream key in the webrtcingest hook 477 + const [remoteMediaStream, setRemoteMediaStream] = useWebRTCIngest({ 478 + endpoint: `${url}/api/ingest/webrtc`, 479 + }); 480 + 481 + useEffect(() => { 482 + if (ingestMediaSource === IngestMediaSource.DISPLAY) { 483 + navigator.mediaDevices 484 + .getDisplayMedia({ 485 + audio: true, 486 + video: true, 487 + }) 488 + .then((stream) => { 489 + setLocalMediaStream(stream); 490 + }) 491 + .catch((e) => { 492 + console.error("error getting display media", e); 493 + }); 494 + } else { 495 + navigator.mediaDevices 496 + .getUserMedia({ 497 + audio: true, 498 + video: { 499 + width: { min: 200, ideal: 1080, max: 2160 }, 500 + height: { min: 200, ideal: 1920, max: 3840 }, 501 + }, 502 + }) 503 + .then((stream) => { 504 + setLocalMediaStream(stream); 505 + }) 506 + .catch((e) => { 507 + console.error("error getting user media", e.name); 508 + if (e.name == "NotAllowedError") { 509 + setError( 510 + new Error( 511 + "Unable to access video! Please allow it in your browser settings.", 512 + ), 513 + ); 514 + } 515 + }); 516 + } 517 + }, [ingestMediaSource]); 518 + 519 + useEffect(() => { 520 + if (!ingestStarting && !ingestAutoStart) { 521 + setRemoteMediaStream(null); 522 + return; 523 + } 524 + if (!localMediaStream) { 525 + return; 526 + } 527 + setRemoteMediaStream(localMediaStream); 528 + }, [localMediaStream, ingestStarting, ingestAutoStart]); 529 + 530 + useEffect(() => { 531 + if (!videoElement) { 532 + return; 533 + } 534 + if (!localMediaStream) { 535 + return; 536 + } 537 + videoElement.srcObject = localMediaStream; 538 + }, [videoElement, localMediaStream]); 539 + 540 + if (error) { 541 + return ( 542 + <View 543 + backgroundColor={colors.destructive[900]} 544 + style={[p[4], { borderRadius: borderRadius.md }]} 545 + > 546 + <View> 547 + <Text size="xl" weight="extrabold"> 548 + Error encountered! 549 + </Text> 550 + </View> 551 + <Text>{error.message}</Text> 552 + </View> 553 + ); 554 + } 555 + 556 + return <VideoElement {...props} ref={handleRef} />; 557 + }
+145
js/components/src/components/mobile-player/webrtc-diagnostics.tsx
··· 1 + import { useEffect, useState } from "react"; 2 + 3 + export interface WebRTCDiagnostics { 4 + done: boolean; 5 + browserSupport: boolean; 6 + rtcPeerConnection: boolean; 7 + rtcSessionDescription: boolean; 8 + getUserMedia: boolean; 9 + getDisplayMedia: boolean; 10 + errors: string[]; 11 + warnings: string[]; 12 + } 13 + 14 + export function useWebRTCDiagnostics(): WebRTCDiagnostics { 15 + const [diagnostics, setDiagnostics] = useState<WebRTCDiagnostics>({ 16 + done: false, 17 + browserSupport: false, 18 + rtcPeerConnection: false, 19 + rtcSessionDescription: false, 20 + getUserMedia: false, 21 + getDisplayMedia: false, 22 + errors: [], 23 + warnings: [], 24 + }); 25 + 26 + useEffect(() => { 27 + const errors: string[] = []; 28 + const warnings: string[] = []; 29 + 30 + // Check if we're in a browser environment 31 + if (typeof window === "undefined") { 32 + errors.push("Running in non-browser environment"); 33 + setDiagnostics({ 34 + done: false, 35 + browserSupport: false, 36 + rtcPeerConnection: false, 37 + rtcSessionDescription: false, 38 + getUserMedia: false, 39 + getDisplayMedia: false, 40 + errors, 41 + warnings, 42 + }); 43 + return; 44 + } 45 + 46 + // Check RTCPeerConnection support 47 + const rtcPeerConnection = !!( 48 + window.RTCPeerConnection || 49 + (window as any).webkitRTCPeerConnection || 50 + (window as any).mozRTCPeerConnection 51 + ); 52 + 53 + if (!rtcPeerConnection) { 54 + errors.push("RTCPeerConnection is not supported"); 55 + } 56 + 57 + // Check RTCSessionDescription support 58 + const rtcSessionDescription = !!( 59 + window.RTCSessionDescription || 60 + (window as any).webkitRTCSessionDescription || 61 + (window as any).mozRTCSessionDescription 62 + ); 63 + 64 + if (!rtcSessionDescription) { 65 + errors.push("RTCSessionDescription is not supported"); 66 + } 67 + 68 + // Check getUserMedia support 69 + const getUserMedia = !!( 70 + navigator.mediaDevices?.getUserMedia || 71 + (navigator as any).getUserMedia || 72 + (navigator as any).webkitGetUserMedia || 73 + (navigator as any).mozGetUserMedia 74 + ); 75 + 76 + if (!getUserMedia) { 77 + warnings.push( 78 + "getUserMedia is not supported - webcam features unavailable", 79 + ); 80 + } 81 + 82 + // Check getDisplayMedia support 83 + const getDisplayMedia = !!navigator.mediaDevices?.getDisplayMedia; 84 + 85 + if (!getDisplayMedia) { 86 + warnings.push( 87 + "getDisplayMedia is not supported - screen sharing unavailable", 88 + ); 89 + } 90 + 91 + // Check if running over HTTPS (required for some WebRTC features) 92 + if (location.protocol !== "https:" && location.hostname !== "localhost") { 93 + warnings.push("WebRTC features may be limited over HTTP connections"); 94 + } 95 + 96 + // Check browser-specific issues 97 + const userAgent = navigator.userAgent.toLowerCase(); 98 + if (userAgent.includes("safari") && !userAgent.includes("chrome")) { 99 + warnings.push("Safari may have limited WebRTC codec support"); 100 + } 101 + 102 + const browserSupport = rtcPeerConnection && rtcSessionDescription; 103 + 104 + setDiagnostics({ 105 + done: true, 106 + browserSupport, 107 + rtcPeerConnection, 108 + rtcSessionDescription, 109 + getUserMedia, 110 + getDisplayMedia, 111 + errors, 112 + warnings, 113 + }); 114 + }, []); 115 + 116 + return diagnostics; 117 + } 118 + 119 + export function logWebRTCDiagnostics() { 120 + console.group("WebRTC Diagnostics"); 121 + 122 + // Log browser support 123 + console.log("RTCPeerConnection:", !!window.RTCPeerConnection); 124 + console.log("RTCSessionDescription:", !!window.RTCSessionDescription); 125 + console.log("getUserMedia:", !!navigator.mediaDevices?.getUserMedia); 126 + console.log("getDisplayMedia:", !!navigator.mediaDevices?.getDisplayMedia); 127 + 128 + // Log browser info 129 + console.log("User Agent:", navigator.userAgent); 130 + console.log("Protocol:", location.protocol); 131 + console.log("Host:", location.hostname); 132 + 133 + // Test basic WebRTC functionality 134 + if (window.RTCPeerConnection) { 135 + try { 136 + const pc = new RTCPeerConnection(); 137 + console.log("RTCPeerConnection creation: ✓ Success"); 138 + pc.close(); 139 + } catch (error) { 140 + console.error("RTCPeerConnection creation: ✗ Failed", error); 141 + } 142 + } 143 + 144 + console.groupEnd(); 145 + }
+6
js/components/src/components/mobile-player/webrtc-primitives.native.tsx
··· 1 + export { 2 + RTCPeerConnection, 3 + RTCSessionDescription, 4 + MediaStream as WebRTCMediaStream, 5 + mediaDevices, 6 + } from "react-native-webrtc";
+33
js/components/src/components/mobile-player/webrtc-primitives.tsx
··· 1 + // Browser compatibility checks for WebRTC 2 + const checkWebRTCSupport = () => { 3 + if (typeof window === "undefined") { 4 + throw new Error("WebRTC is not available in non-browser environments"); 5 + } 6 + 7 + if (!window.RTCPeerConnection) { 8 + throw new Error( 9 + "RTCPeerConnection is not supported in this browser. Please use a modern browser that supports WebRTC.", 10 + ); 11 + } 12 + 13 + if (!window.RTCSessionDescription) { 14 + throw new Error( 15 + "RTCSessionDescription is not supported in this browser. Please use a modern browser that supports WebRTC.", 16 + ); 17 + } 18 + }; 19 + 20 + // Check support immediately 21 + try { 22 + checkWebRTCSupport(); 23 + } catch (error) { 24 + console.error("WebRTC Compatibility Error:", error.message); 25 + } 26 + 27 + export const RTCPeerConnection = window.RTCPeerConnection; 28 + export const RTCSessionDescription = window.RTCSessionDescription; 29 + export const WebRTCMediaStream = window.MediaStream; 30 + export const mediaDevices = navigator.mediaDevices; 31 + 32 + // Export the compatibility checker for use in other components 33 + export { checkWebRTCSupport };
+309
js/components/src/components/ui/button.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import React, { forwardRef, useMemo } from "react"; 3 + import { ActivityIndicator, StyleSheet } from "react-native"; 4 + import { useTheme } from "../../lib/theme/theme"; 5 + import * as tokens from "../../lib/theme/tokens"; 6 + import { ButtonPrimitive, ButtonPrimitiveProps } from "./primitives/button"; 7 + import { TextPrimitive } from "./primitives/text"; 8 + 9 + // Button variants using class-variance-authority pattern 10 + const buttonVariants = cva("", { 11 + variants: { 12 + variant: { 13 + primary: "primary", 14 + secondary: "secondary", 15 + outline: "outline", 16 + ghost: "ghost", 17 + destructive: "destructive", 18 + success: "success", 19 + }, 20 + size: { 21 + sm: "sm", 22 + md: "md", 23 + lg: "lg", 24 + xl: "xl", 25 + pill: "pill", 26 + }, 27 + }, 28 + defaultVariants: { 29 + variant: "primary", 30 + size: "md", 31 + }, 32 + }); 33 + 34 + export interface ButtonProps 35 + extends Omit<ButtonPrimitiveProps, "children">, 36 + VariantProps<typeof buttonVariants> { 37 + children?: React.ReactNode; 38 + leftIcon?: React.ReactNode; 39 + rightIcon?: React.ReactNode; 40 + loading?: boolean; 41 + loadingText?: string; 42 + } 43 + 44 + export const Button = forwardRef<any, ButtonProps>( 45 + ( 46 + { 47 + variant = "primary", 48 + size = "md", 49 + children, 50 + leftIcon, 51 + rightIcon, 52 + loading = false, 53 + loadingText, 54 + disabled, 55 + style, 56 + ...props 57 + }, 58 + ref, 59 + ) => { 60 + const { theme } = useTheme(); 61 + 62 + // Create dynamic styles based on theme 63 + const styles = useMemo(() => createStyles(theme), [theme]); 64 + 65 + // Get variant styles 66 + const buttonStyle = useMemo(() => { 67 + const variantStyle = styles[`${variant}Button` as keyof typeof styles]; 68 + const sizeStyle = styles[`${size}Button` as keyof typeof styles]; 69 + return [variantStyle, sizeStyle]; 70 + }, [variant, size, styles]); 71 + 72 + // Get inner styles for button content 73 + const buttonInnerStyle = useMemo(() => { 74 + const sizeInnerStyle = 75 + styles[`${size}ButtonInner` as keyof typeof styles]; 76 + return sizeInnerStyle; 77 + }, [size, styles]); 78 + 79 + const textStyle = React.useMemo(() => { 80 + const variantTextStyle = styles[`${variant}Text` as keyof typeof styles]; 81 + const sizeTextStyle = styles[`${size}Text` as keyof typeof styles]; 82 + return [variantTextStyle, sizeTextStyle]; 83 + }, [variant, size, styles]); 84 + 85 + const iconSize = React.useMemo(() => { 86 + switch (size) { 87 + case "sm": 88 + return 16; 89 + case "lg": 90 + return 20; 91 + case "xl": 92 + return 24; 93 + case "md": 94 + default: 95 + return 18; 96 + } 97 + }, [size]); 98 + 99 + const spinnerSize = useMemo(() => { 100 + switch (size) { 101 + case "sm": 102 + return "small" as const; 103 + case "lg": 104 + case "xl": 105 + return "large" as const; 106 + case "md": 107 + default: 108 + return "small" as const; 109 + } 110 + }, [size]); 111 + 112 + const spinnerColor = useMemo(() => { 113 + switch (variant) { 114 + case "outline": 115 + case "ghost": 116 + return theme.colors.primary; 117 + case "secondary": 118 + return theme.colors.secondaryForeground; 119 + case "destructive": 120 + return theme.colors.destructiveForeground; 121 + default: 122 + return theme.colors.primaryForeground; 123 + } 124 + }, [variant, theme.colors]); 125 + 126 + return ( 127 + <ButtonPrimitive.Root 128 + ref={ref} 129 + disabled={disabled || loading} 130 + style={[buttonStyle, style]} 131 + {...props} 132 + > 133 + <ButtonPrimitive.Content style={buttonInnerStyle}> 134 + {loading && !leftIcon ? ( 135 + <ButtonPrimitive.Icon position="left"> 136 + <ActivityIndicator size={spinnerSize} color={spinnerColor} /> 137 + </ButtonPrimitive.Icon> 138 + ) : leftIcon ? ( 139 + <ButtonPrimitive.Icon 140 + position="left" 141 + style={{ width: iconSize, height: iconSize }} 142 + > 143 + {leftIcon} 144 + </ButtonPrimitive.Icon> 145 + ) : null} 146 + 147 + <TextPrimitive.Root style={textStyle}> 148 + {loading && loadingText ? loadingText : children} 149 + </TextPrimitive.Root> 150 + 151 + {loading && rightIcon ? ( 152 + <ButtonPrimitive.Icon position="right"> 153 + <ActivityIndicator size={spinnerSize} color={spinnerColor} /> 154 + </ButtonPrimitive.Icon> 155 + ) : rightIcon ? ( 156 + <ButtonPrimitive.Icon 157 + position="right" 158 + style={{ width: iconSize, height: iconSize }} 159 + > 160 + {rightIcon} 161 + </ButtonPrimitive.Icon> 162 + ) : null} 163 + </ButtonPrimitive.Content> 164 + </ButtonPrimitive.Root> 165 + ); 166 + }, 167 + ); 168 + 169 + Button.displayName = "Button"; 170 + 171 + // Create theme-based styles 172 + function createStyles(theme: any) { 173 + return StyleSheet.create({ 174 + // Variant styles 175 + primaryButton: { 176 + backgroundColor: theme.colors.primary, 177 + borderWidth: 0, 178 + ...theme.shadows.sm, 179 + }, 180 + primaryText: { 181 + color: theme.colors.primaryForeground, 182 + fontWeight: "600", 183 + }, 184 + 185 + secondaryButton: { 186 + backgroundColor: theme.colors.secondary, 187 + borderWidth: 0, 188 + }, 189 + secondaryText: { 190 + color: theme.colors.secondaryForeground, 191 + fontWeight: "500", 192 + }, 193 + 194 + outlineButton: { 195 + backgroundColor: "transparent", 196 + borderWidth: 1, 197 + borderColor: theme.colors.border, 198 + }, 199 + outlineText: { 200 + color: theme.colors.foreground, 201 + fontWeight: "500", 202 + }, 203 + 204 + ghostButton: { 205 + backgroundColor: "transparent", 206 + borderWidth: 0, 207 + }, 208 + ghostText: { 209 + color: theme.colors.foreground, 210 + fontWeight: "500", 211 + }, 212 + 213 + destructiveButton: { 214 + backgroundColor: theme.colors.destructive, 215 + borderWidth: 0, 216 + ...theme.shadows.sm, 217 + }, 218 + destructiveText: { 219 + color: theme.colors.destructiveForeground, 220 + fontWeight: "600", 221 + }, 222 + 223 + successButton: { 224 + backgroundColor: theme.colors.success, 225 + borderWidth: 0, 226 + ...theme.shadows.sm, 227 + }, 228 + successText: { 229 + color: theme.colors.successForeground, 230 + fontWeight: "600", 231 + }, 232 + 233 + pillButton: { 234 + paddingHorizontal: theme.spacing[3], 235 + paddingVertical: theme.spacing[2], 236 + borderRadius: tokens.borderRadius.full, 237 + minHeight: tokens.touchTargets.minimum / 2, 238 + }, 239 + 240 + pillText: { 241 + color: theme.colors.primaryForeground, 242 + fontWeight: "400", 243 + }, 244 + 245 + // Size styles 246 + smButton: { 247 + paddingHorizontal: theme.spacing[3], 248 + paddingVertical: theme.spacing[2], 249 + borderRadius: tokens.borderRadius.md, 250 + minHeight: tokens.touchTargets.minimum, 251 + gap: theme.spacing[1], 252 + }, 253 + smButtonInner: { 254 + gap: theme.spacing[1], 255 + }, 256 + smText: { 257 + fontSize: 14, 258 + lineHeight: 16, 259 + }, 260 + 261 + mdButton: { 262 + paddingHorizontal: theme.spacing[4], 263 + paddingVertical: theme.spacing[3], 264 + borderRadius: tokens.borderRadius.md, 265 + minHeight: tokens.touchTargets.minimum, 266 + gap: theme.spacing[2], 267 + }, 268 + mdButtonInner: { 269 + gap: theme.spacing[2], 270 + }, 271 + mdText: { 272 + fontSize: 16, 273 + lineHeight: 18, 274 + }, 275 + 276 + lgButton: { 277 + paddingHorizontal: theme.spacing[6], 278 + paddingVertical: theme.spacing[4], 279 + borderRadius: tokens.borderRadius.md, 280 + minHeight: tokens.touchTargets.comfortable, 281 + gap: theme.spacing[3], 282 + }, 283 + lgButtonInner: { 284 + gap: theme.spacing[3], 285 + }, 286 + lgText: { 287 + fontSize: 18, 288 + lineHeight: 20, 289 + }, 290 + 291 + xlButton: { 292 + paddingHorizontal: theme.spacing[8], 293 + paddingVertical: theme.spacing[5], 294 + borderRadius: tokens.borderRadius.lg, 295 + minHeight: tokens.touchTargets.large, 296 + gap: theme.spacing[4], 297 + }, 298 + xlButtonInner: { 299 + gap: theme.spacing[4], 300 + }, 301 + xlText: { 302 + fontSize: 20, 303 + lineHeight: 24, 304 + }, 305 + }); 306 + } 307 + 308 + // Export button variants for external use 309 + export { buttonVariants };
+376
js/components/src/components/ui/dialog.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import { X } from "lucide-react-native"; 3 + import React, { forwardRef } from "react"; 4 + import { Platform, StyleSheet, Text } from "react-native"; 5 + import { useTheme } from "../../lib/theme/theme"; 6 + import { createThemedIcon } from "./icons"; 7 + import { ModalPrimitive, ModalPrimitiveProps } from "./primitives/modal"; 8 + 9 + const ThemedX = createThemedIcon(X); 10 + 11 + // Dialog variants using class-variance-authority pattern 12 + const dialogVariants = cva("", { 13 + variants: { 14 + variant: { 15 + default: "default", 16 + sheet: "sheet", 17 + fullscreen: "fullscreen", 18 + }, 19 + size: { 20 + sm: "sm", 21 + md: "md", 22 + lg: "lg", 23 + xl: "xl", 24 + full: "full", 25 + }, 26 + position: { 27 + center: "center", 28 + top: "top", 29 + bottom: "bottom", 30 + left: "left", 31 + right: "right", 32 + }, 33 + }, 34 + defaultVariants: { 35 + variant: "default", 36 + size: "md", 37 + position: "center", 38 + }, 39 + }); 40 + 41 + export interface DialogProps 42 + extends Omit<ModalPrimitiveProps, "children">, 43 + VariantProps<typeof dialogVariants> { 44 + children?: React.ReactNode; 45 + title?: string; 46 + description?: string; 47 + dismissible?: boolean; 48 + showCloseButton?: boolean; 49 + onClose?: () => void; 50 + } 51 + 52 + export const Dialog = forwardRef<any, DialogProps>( 53 + ( 54 + { 55 + variant = "left", 56 + size = "md", 57 + position = "center", 58 + children, 59 + title, 60 + description, 61 + dismissible = true, 62 + showCloseButton = true, 63 + onClose, 64 + open = false, 65 + onOpenChange, 66 + ...props 67 + }, 68 + ref, 69 + ) => { 70 + const { theme } = useTheme(); 71 + 72 + // Create dynamic styles based on theme 73 + const styles = React.useMemo(() => createStyles(theme), [theme]); 74 + 75 + const handleClose = React.useCallback(() => { 76 + if (onClose) { 77 + onClose(); 78 + } 79 + if (onOpenChange) { 80 + onOpenChange(false); 81 + } 82 + }, [onClose, onOpenChange]); 83 + 84 + const presentationStyle = React.useMemo(() => { 85 + if (variant === "sheet" && Platform.OS === "ios") { 86 + return "pageSheet" as const; 87 + } 88 + if (variant === "fullscreen") { 89 + return "fullScreen" as const; 90 + } 91 + return Platform.OS === "ios" 92 + ? ("pageSheet" as const) 93 + : ("fullScreen" as const); 94 + }, [variant]); 95 + 96 + const animationType = React.useMemo(() => { 97 + if (variant === "sheet") { 98 + return "slide" as const; 99 + } 100 + return "fade" as const; 101 + }, [variant]); 102 + 103 + return ( 104 + <ModalPrimitive.Root 105 + ref={ref} 106 + open={open} 107 + onOpenChange={onOpenChange} 108 + presentationStyle={presentationStyle} 109 + animationType={animationType} 110 + {...props} 111 + > 112 + <ModalPrimitive.Overlay 113 + dismissible={dismissible} 114 + onDismiss={handleClose} 115 + style={styles.overlay} 116 + > 117 + <ModalPrimitive.Content 118 + position={position || "left"} 119 + size={size || "md"} 120 + style={[ 121 + styles.content, 122 + styles[`${variant}Content` as keyof typeof styles], 123 + styles[`${size}Content` as keyof typeof styles], 124 + ]} 125 + > 126 + {(title || showCloseButton) && ( 127 + <ModalPrimitive.Header 128 + withBorder={variant !== "sheet"} 129 + style={styles.header} 130 + > 131 + <DialogTitle>{title}</DialogTitle> 132 + {showCloseButton && ( 133 + <ModalPrimitive.Close 134 + onClose={handleClose} 135 + style={styles.closeButton} 136 + > 137 + <DialogCloseIcon /> 138 + </ModalPrimitive.Close> 139 + )} 140 + </ModalPrimitive.Header> 141 + )} 142 + 143 + <ModalPrimitive.Body 144 + scrollable={variant !== "fullscreen"} 145 + style={styles.body} 146 + > 147 + {description && ( 148 + <DialogDescription>{description}</DialogDescription> 149 + )} 150 + {children} 151 + </ModalPrimitive.Body> 152 + </ModalPrimitive.Content> 153 + </ModalPrimitive.Overlay> 154 + </ModalPrimitive.Root> 155 + ); 156 + }, 157 + ); 158 + 159 + Dialog.displayName = "Dialog"; 160 + 161 + // Dialog Title component 162 + export interface DialogTitleProps { 163 + children?: React.ReactNode; 164 + style?: any; 165 + } 166 + 167 + export const DialogTitle = forwardRef<any, DialogTitleProps>( 168 + ({ children, style, ...props }, ref) => { 169 + const { theme } = useTheme(); 170 + const styles = React.useMemo(() => createStyles(theme), [theme]); 171 + 172 + if (!children) return null; 173 + 174 + return ( 175 + <Text ref={ref} style={[styles.title, style]} {...props}> 176 + {children} 177 + </Text> 178 + ); 179 + }, 180 + ); 181 + 182 + DialogTitle.displayName = "DialogTitle"; 183 + 184 + // Dialog Description component 185 + export interface DialogDescriptionProps { 186 + children?: React.ReactNode; 187 + style?: any; 188 + } 189 + 190 + export const DialogDescription = forwardRef<any, DialogDescriptionProps>( 191 + ({ children, style, ...props }, ref) => { 192 + const { theme } = useTheme(); 193 + const styles = React.useMemo(() => createStyles(theme), [theme]); 194 + 195 + if (!children) return null; 196 + 197 + return ( 198 + <Text ref={ref} style={[styles.description, style]} {...props}> 199 + {children} 200 + </Text> 201 + ); 202 + }, 203 + ); 204 + 205 + DialogDescription.displayName = "DialogDescription"; 206 + 207 + // Dialog Footer component 208 + export interface DialogFooterProps { 209 + children?: React.ReactNode; 210 + direction?: "row" | "column"; 211 + justify?: 212 + | "flex-start" 213 + | "center" 214 + | "flex-end" 215 + | "space-between" 216 + | "space-around"; 217 + withBorder?: boolean; 218 + style?: any; 219 + } 220 + 221 + export const DialogFooter = forwardRef<any, DialogFooterProps>( 222 + ( 223 + { 224 + children, 225 + direction = "row", 226 + justify = "flex-end", 227 + withBorder = true, 228 + style, 229 + ...props 230 + }, 231 + ref, 232 + ) => { 233 + const { theme } = useTheme(); 234 + const styles = React.useMemo(() => createStyles(theme), [theme]); 235 + 236 + if (!children) return null; 237 + 238 + return ( 239 + <ModalPrimitive.Footer 240 + ref={ref} 241 + withBorder={withBorder} 242 + direction={direction} 243 + justify={justify} 244 + style={[styles.footer, style]} 245 + {...props} 246 + > 247 + {children} 248 + </ModalPrimitive.Footer> 249 + ); 250 + }, 251 + ); 252 + 253 + DialogFooter.displayName = "DialogFooter"; 254 + 255 + // Dialog Close Icon component (Lucide X) 256 + const DialogCloseIcon = () => { 257 + return <ThemedX size="md" variant="muted" />; 258 + }; 259 + 260 + // Create theme-aware styles 261 + function createStyles(theme: any) { 262 + return StyleSheet.create({ 263 + overlay: { 264 + backgroundColor: "rgba(0, 0, 0, 0.5)", 265 + }, 266 + 267 + content: { 268 + backgroundColor: theme.colors.card, 269 + borderRadius: theme.borderRadius.lg, 270 + ...theme.shadows.lg, 271 + maxHeight: "90%", 272 + maxWidth: "90%", 273 + }, 274 + 275 + // Variant styles 276 + defaultContent: { 277 + // Default styles already applied in content 278 + }, 279 + 280 + sheetContent: { 281 + borderTopLeftRadius: theme.borderRadius.xl, 282 + borderTopRightRadius: theme.borderRadius.xl, 283 + borderBottomLeftRadius: 0, 284 + borderBottomRightRadius: 0, 285 + marginTop: "auto", 286 + marginBottom: 0, 287 + maxHeight: "80%", 288 + width: "100%", 289 + maxWidth: "100%", 290 + }, 291 + 292 + fullscreenContent: { 293 + width: "100%", 294 + height: "100%", 295 + maxWidth: "100%", 296 + maxHeight: "100%", 297 + borderRadius: 0, 298 + margin: 0, 299 + }, 300 + 301 + // Size styles 302 + smContent: { 303 + minWidth: 300, 304 + minHeight: 200, 305 + }, 306 + 307 + mdContent: { 308 + minWidth: 400, 309 + minHeight: 300, 310 + }, 311 + 312 + lgContent: { 313 + minWidth: 500, 314 + minHeight: 400, 315 + }, 316 + 317 + xlContent: { 318 + minWidth: 600, 319 + minHeight: 500, 320 + }, 321 + 322 + fullContent: { 323 + width: "95%", 324 + height: "95%", 325 + maxWidth: "95%", 326 + maxHeight: "95%", 327 + }, 328 + 329 + header: { 330 + paddingHorizontal: theme.spacing[6], 331 + paddingVertical: theme.spacing[4], 332 + flexDirection: "row", 333 + alignItems: "center", 334 + justifyContent: "space-between", 335 + }, 336 + 337 + body: { 338 + paddingHorizontal: theme.spacing[6], 339 + paddingBottom: theme.spacing[6], 340 + flex: 1, 341 + }, 342 + 343 + footer: { 344 + paddingHorizontal: theme.spacing[6], 345 + paddingVertical: theme.spacing[4], 346 + gap: theme.spacing[2], 347 + }, 348 + 349 + title: { 350 + fontSize: 20, 351 + fontWeight: "600", 352 + color: theme.colors.text, 353 + flex: 1, 354 + lineHeight: 24, 355 + }, 356 + 357 + description: { 358 + fontSize: 16, 359 + color: theme.colors.textMuted, 360 + lineHeight: 22, 361 + marginBottom: theme.spacing[4], 362 + }, 363 + 364 + closeButton: { 365 + width: theme.touchTargets.minimum, 366 + height: theme.touchTargets.minimum, 367 + alignItems: "center", 368 + justifyContent: "center", 369 + borderRadius: theme.borderRadius.sm, 370 + marginLeft: theme.spacing[2], 371 + }, 372 + }); 373 + } 374 + 375 + // Export dialog variants for external use 376 + export { dialogVariants };
+424
js/components/src/components/ui/dropdown.tsx
··· 1 + import BottomSheet, { BottomSheetView } from "@gorhom/bottom-sheet"; 2 + import * as DropdownMenuPrimitive from "@rn-primitives/dropdown-menu"; 3 + import { 4 + Check, 5 + CheckCircle, 6 + ChevronDown, 7 + ChevronRight, 8 + ChevronUp, 9 + Circle, 10 + } from "lucide-react-native"; 11 + import { forwardRef, ReactNode, useMemo, useRef } from "react"; 12 + import { 13 + Platform, 14 + StyleSheet, 15 + Text, 16 + useWindowDimensions, 17 + View, 18 + } from "react-native"; 19 + import { 20 + a, 21 + bg, 22 + borderRadius, 23 + colors, 24 + fontSize, 25 + gap, 26 + h, 27 + layout, 28 + ml, 29 + mt, 30 + mx, 31 + my, 32 + p, 33 + pb, 34 + pl, 35 + pr, 36 + pt, 37 + px, 38 + py, 39 + right, 40 + textColors, 41 + } from "../../lib/theme/atoms"; 42 + import { 43 + objectFromObjects, 44 + TextContext as TextClassContext, 45 + } from "./primitives/text"; 46 + 47 + export const DropdownMenu = DropdownMenuPrimitive.Root; 48 + export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; 49 + export const DropdownMenuPortal = DropdownMenuPrimitive.Portal; 50 + export const DropdownMenuSub = DropdownMenuPrimitive.Sub; 51 + export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; 52 + 53 + export const DropdownMenuBottomSheet = forwardRef< 54 + any, 55 + DropdownMenuPrimitive.ContentProps & { 56 + overlayStyle?: any; 57 + portalHost?: string; 58 + } 59 + >(function DropdownMenuBottomSheet( 60 + { overlayStyle, portalHost, children }, 61 + _ref, 62 + ) { 63 + // Use the primitives' context to know if open 64 + const { open, onOpenChange } = DropdownMenuPrimitive.useRootContext(); 65 + const snapPoints = useMemo(() => ["25%", "50%", "80%"], []); 66 + const sheetRef = useRef<BottomSheet>(null); 67 + 68 + return ( 69 + <DropdownMenuPrimitive.Portal hostName={portalHost}> 70 + <BottomSheet 71 + ref={sheetRef} 72 + // why the heck is this 1-indexed 73 + index={open ? 3 : -1} 74 + snapPoints={snapPoints} 75 + enablePanDownToClose 76 + onClose={() => onOpenChange?.(false)} 77 + style={[overlayStyle]} 78 + backgroundStyle={[bg.black, a.radius.all.md, a.shadows.md, p[1]]} 79 + handleIndicatorStyle={[ 80 + a.sizes.width[12], 81 + a.sizes.height[1], 82 + bg.gray[500], 83 + ]} 84 + > 85 + <BottomSheetView style={[px[2]]}> 86 + {typeof children === "function" 87 + ? children({ pressed: true }) 88 + : children} 89 + </BottomSheetView> 90 + </BottomSheet> 91 + </DropdownMenuPrimitive.Portal> 92 + ); 93 + }); 94 + 95 + export const DropdownMenuSubTrigger = forwardRef< 96 + any, 97 + DropdownMenuPrimitive.SubTriggerProps & { inset?: boolean } & { 98 + ref?: React.RefObject<DropdownMenuPrimitive.SubTriggerRef>; 99 + className?: string; 100 + inset?: boolean; 101 + children?: React.ReactNode; 102 + } 103 + >(({ inset, children, ...props }, ref) => { 104 + const { open } = DropdownMenuPrimitive.useSubContext(); 105 + const Icon = 106 + Platform.OS === "web" ? ChevronRight : open ? ChevronUp : ChevronDown; 107 + return ( 108 + <TextClassContext.Provider 109 + value={objectFromObjects([ 110 + a.textColors.primary[500], 111 + a.fontSize.base, 112 + open && a.textColors.primary[700], 113 + ])} 114 + > 115 + <DropdownMenuPrimitive.SubTrigger ref={ref} {...props}> 116 + <View 117 + style={[ 118 + inset && gap[2], 119 + layout.flex.row, 120 + layout.flex.alignCenter, 121 + p[2], 122 + pr[8], 123 + ]} 124 + > 125 + {children} 126 + <View style={[a.layout.position.absolute, a.position.right[1]]}> 127 + <Icon size={18} color={colors.gray[200]} /> 128 + </View> 129 + </View> 130 + </DropdownMenuPrimitive.SubTrigger> 131 + </TextClassContext.Provider> 132 + ); 133 + }); 134 + 135 + export const DropdownMenuSubContent = forwardRef< 136 + any, 137 + DropdownMenuPrimitive.SubContentProps 138 + >((props, ref) => { 139 + return ( 140 + <DropdownMenuPrimitive.SubContent 141 + ref={ref} 142 + style={[ 143 + a.zIndex[50], 144 + a.sizes.minWidth[32], 145 + a.overflow.hidden, 146 + a.radius.all.md, 147 + a.borders.width.thin, 148 + a.borders.color.gray[600], 149 + mt[1], 150 + bg.black, 151 + p[1], 152 + a.shadows.md, 153 + ]} 154 + {...props} 155 + /> 156 + ); 157 + }); 158 + 159 + export const DropdownMenuContent = forwardRef< 160 + any, 161 + DropdownMenuPrimitive.ContentProps & { 162 + overlayStyle?: any; 163 + portalHost?: string; 164 + } 165 + >(({ overlayStyle, portalHost, ...props }, ref) => { 166 + return ( 167 + <DropdownMenuPrimitive.Portal hostName={portalHost}> 168 + <DropdownMenuPrimitive.Overlay 169 + style={[ 170 + Platform.OS !== "web" ? StyleSheet.absoluteFill : undefined, 171 + overlayStyle, 172 + ]} 173 + > 174 + <DropdownMenuPrimitive.Content 175 + ref={ref} 176 + style={ 177 + [ 178 + a.zIndex[50], 179 + a.sizes.minWidth[32], 180 + a.sizes.maxWidth[64], 181 + a.overflow.hidden, 182 + a.radius.all.md, 183 + a.borders.width.thin, 184 + a.borders.color.gray[800], 185 + bg.gray[950], 186 + p[2], 187 + a.shadows.md, 188 + ] as any 189 + } 190 + {...props} 191 + /> 192 + </DropdownMenuPrimitive.Overlay> 193 + </DropdownMenuPrimitive.Portal> 194 + ); 195 + }); 196 + 197 + export const ResponsiveDropdownMenuContent = forwardRef<any, any>( 198 + ({ children, ...props }, ref) => { 199 + const { width } = useWindowDimensions(); 200 + 201 + // On web, you might want to always use the normal dropdown 202 + const isBottomSheet = Platform.OS !== "web" && width < 800; 203 + 204 + if (isBottomSheet) { 205 + return ( 206 + <DropdownMenuBottomSheet ref={ref} {...props}> 207 + {children} 208 + </DropdownMenuBottomSheet> 209 + ); 210 + } 211 + return ( 212 + <DropdownMenuContent ref={ref} {...props}> 213 + {children} 214 + </DropdownMenuContent> 215 + ); 216 + }, 217 + ); 218 + 219 + import React from "react"; 220 + import { Animated, Pressable } from "react-native"; 221 + 222 + export const DropdownMenuItem = forwardRef< 223 + any, 224 + DropdownMenuPrimitive.ItemProps & { inset: boolean; disabled: boolean } 225 + >(({ inset, disabled, style, children, ...props }, ref) => { 226 + // Neutral background colors 227 + const NEUTRAL_BG = colors.gray[800]; // "#262626" 228 + const NEUTRAL_BG_LIGHT = colors.gray[700]; // "#404040" 229 + const anim = useRef(new Animated.Value(0)).current; 230 + 231 + const handlePressIn = () => { 232 + Animated.timing(anim, { 233 + toValue: 1, 234 + duration: 80, 235 + useNativeDriver: false, 236 + }).start(); 237 + }; 238 + 239 + const handlePressOut = () => { 240 + Animated.timing(anim, { 241 + toValue: 0, 242 + duration: 80, 243 + useNativeDriver: false, 244 + }).start(); 245 + }; 246 + 247 + const bgColor = anim.interpolate({ 248 + inputRange: [0, 1], 249 + outputRange: [NEUTRAL_BG, NEUTRAL_BG_LIGHT], 250 + }); 251 + 252 + return ( 253 + <TextClassContext.Provider 254 + value={objectFromObjects([a.textColors.gray[900], a.fontSize.base])} 255 + > 256 + <Pressable 257 + onPressIn={handlePressIn} 258 + onPressOut={handlePressOut} 259 + disabled={disabled} 260 + style={({ pressed }) => [ 261 + { opacity: disabled ? 0.5 : 1 }, 262 + inset && gap[2], 263 + style, 264 + ]} 265 + {...props} 266 + > 267 + <Animated.View 268 + style={{ 269 + backgroundColor: bgColor, 270 + borderRadius: 8, 271 + paddingVertical: 8, 272 + paddingHorizontal: 12, 273 + }} 274 + > 275 + {typeof children === "function" 276 + ? children({ pressed: true }) 277 + : children} 278 + </Animated.View> 279 + </Pressable> 280 + </TextClassContext.Provider> 281 + ); 282 + }); 283 + 284 + export const DropdownMenuCheckboxItem = forwardRef< 285 + any, 286 + DropdownMenuPrimitive.CheckboxItemProps & { 287 + ref?: React.RefObject<DropdownMenuPrimitive.CheckboxItemRef>; 288 + children?: React.ReactNode; 289 + } 290 + >(({ children, checked, ...props }, ref) => { 291 + return ( 292 + <DropdownMenuPrimitive.CheckboxItem ref={ref} checked={checked} {...props}> 293 + <View 294 + style={[ 295 + a.layout.flex.row, 296 + a.layout.flex.alignCenter, 297 + a.radius.all.sm, 298 + py[2], 299 + pl[2], 300 + pr[2], 301 + pr[8], 302 + ]} 303 + > 304 + {children} 305 + <View style={[pl[1], layout.position.absolute, right[1]]}> 306 + {checked ? ( 307 + <CheckCircle size={14} strokeWidth={3} color="white" /> 308 + ) : ( 309 + <Circle size={14} strokeWidth={3} color={a.colors.gray[400]} /> 310 + )} 311 + </View> 312 + </View> 313 + </DropdownMenuPrimitive.CheckboxItem> 314 + ); 315 + }); 316 + 317 + export const DropdownMenuRadioItem = forwardRef< 318 + any, 319 + DropdownMenuPrimitive.RadioItemProps & { 320 + ref?: React.RefObject<DropdownMenuPrimitive.RadioItemRef>; 321 + children?: React.ReactNode; 322 + } 323 + >(({ children, ...props }, ref) => { 324 + return ( 325 + <DropdownMenuPrimitive.RadioItem ref={ref} {...props}> 326 + <View 327 + style={[ 328 + a.layout.flex.row, 329 + a.layout.flex.alignCenter, 330 + a.radius.all.sm, 331 + py[2], 332 + pl[2], 333 + pr[1], 334 + ]} 335 + > 336 + <View style={[pl[1], layout.position.absolute, right[1]]}> 337 + <DropdownMenuPrimitive.ItemIndicator> 338 + <Check size={14} strokeWidth={3} color="white" /> 339 + </DropdownMenuPrimitive.ItemIndicator> 340 + </View> 341 + {children} 342 + </View> 343 + </DropdownMenuPrimitive.RadioItem> 344 + ); 345 + }); 346 + 347 + export const DropdownMenuLabel = forwardRef< 348 + any, 349 + DropdownMenuPrimitive.LabelProps & { inset?: boolean } 350 + >(({ inset, ...props }, ref) => { 351 + return ( 352 + <Text 353 + ref={ref} 354 + style={[ 355 + px[2], 356 + py[3], 357 + a.textColors.gray[200], 358 + a.fontSize.base, 359 + inset && gap[2], 360 + ]} 361 + {...props} 362 + /> 363 + ); 364 + }); 365 + 366 + export const DropdownMenuSeparator = forwardRef< 367 + any, 368 + DropdownMenuPrimitive.SeparatorProps 369 + >((props, ref) => { 370 + return ( 371 + <View 372 + ref={ref} 373 + style={[mx[1], my[1], h[0.5] || { height: 0.5 }, bg.gray[800]]} 374 + {...props} 375 + /> 376 + ); 377 + }); 378 + 379 + export function DropdownMenuShortcut(props: any) { 380 + return ( 381 + <Text 382 + style={[ 383 + ml.auto, 384 + a.textColors.gray[500], 385 + a.fontSize.sm, 386 + a.letterSpacing.widest, 387 + ]} 388 + {...props} 389 + /> 390 + ); 391 + } 392 + 393 + export const DropdownMenuGroup = forwardRef< 394 + any, 395 + { inset?: boolean; title?: string; children: ReactNode } 396 + >((props, ref) => { 397 + const { inset, title, children, ...rest } = props; 398 + return ( 399 + <View style={[pt[1], inset ? gap[2] : gap[1]]} ref={ref} {...rest}> 400 + {title && ( 401 + <Text style={[textColors.gray[400], pb[1], pl[2]]}>{title}</Text> 402 + )} 403 + <View 404 + style={[ 405 + bg.gray[900], 406 + Platform.OS === "web" ? px[2] : p[2], 407 + { borderRadius: borderRadius.lg }, 408 + ]} 409 + > 410 + {children} 411 + </View> 412 + </View> 413 + ); 414 + }); 415 + 416 + export const DropdownMenuInfo = forwardRef<any, any>( 417 + ({ description, ...props }, ref) => { 418 + return ( 419 + <Text style={[textColors.gray[400], pt[1], pl[2], pb[2], fontSize.sm]}> 420 + {description} 421 + </Text> 422 + ); 423 + }, 424 + );
+50
js/components/src/components/ui/icons.tsx
··· 1 + import { type LucideProps } from "lucide-react-native"; 2 + import React from "react"; 3 + import { useTheme } from "../../lib/theme"; 4 + 5 + // Simple icon wrapper that integrates with theme 6 + export interface IconProps { 7 + variant?: 8 + | "default" 9 + | "muted" 10 + | "primary" 11 + | "secondary" 12 + | "destructive" 13 + | "success" 14 + | "warning"; 15 + size?: number | "sm" | "md" | "lg" | "xl"; 16 + color?: string; 17 + } 18 + 19 + // Size mapping 20 + const sizeMap = { 21 + sm: 16, 22 + md: 20, 23 + lg: 24, 24 + xl: 32, 25 + } as const; 26 + 27 + // HOC to create themed icons 28 + export function createThemedIcon( 29 + IconComponent: React.ComponentType<LucideProps>, 30 + ): React.FC<IconProps> { 31 + return ({ variant = "default", size = "md", color, ...restProps }) => { 32 + let theme = useTheme(); // Ensure theme is available 33 + // Calculate size 34 + const iconSize = typeof size === "number" ? size : sizeMap[size]; 35 + 36 + // Calculate color if not provided using atoms 37 + const iconColor = 38 + color || 39 + theme.theme.colors[variant] || 40 + theme.theme.colors.secondaryForeground; 41 + 42 + return ( 43 + <IconComponent 44 + size={iconSize} 45 + color={iconColor} 46 + {...(restProps as Omit<LucideProps, "size" | "color">)} 47 + /> 48 + ); 49 + }; 50 + }
+31
js/components/src/components/ui/index.ts
··· 1 + // Export primitive components 2 + export * from "./primitives/button"; 3 + export * from "./primitives/input"; 4 + export * from "./primitives/modal"; 5 + export * from "./primitives/text"; 6 + 7 + // Export styled components 8 + export * from "./button"; 9 + export * from "./dialog"; 10 + export * from "./dropdown"; 11 + export * from "./icons"; 12 + export * from "./input"; 13 + export * from "./text"; 14 + export * from "./toast"; 15 + export * from "./view"; 16 + 17 + // Component collections for easy importing 18 + export { ButtonPrimitive } from "./primitives/button"; 19 + export { InputPrimitive } from "./primitives/input"; 20 + export { ModalPrimitive } from "./primitives/modal"; 21 + export { TextPrimitive } from "./primitives/text"; 22 + 23 + // Re-export commonly used types 24 + export type { Theme } from "../../lib/theme/theme"; 25 + export type { ButtonProps } from "./button"; 26 + export type { DialogProps } from "./dialog"; 27 + export type { InputProps } from "./input"; 28 + export type { TextProps } from "./text"; 29 + export type { ViewProps } from "./view"; 30 + 31 + export * from "../../lib/theme";
+350
js/components/src/components/ui/input.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import React, { forwardRef } from "react"; 3 + import { Platform, StyleSheet, TouchableWithoutFeedback } from "react-native"; 4 + import { useTheme } from "../../lib/theme/theme"; 5 + import { InputPrimitive, InputPrimitiveProps } from "./primitives/input"; 6 + 7 + const inputVariants = cva("", { 8 + variants: { 9 + variant: { 10 + default: "default", 11 + filled: "filled", 12 + underlined: "underlined", 13 + }, 14 + size: { 15 + sm: "sm", 16 + md: "md", 17 + lg: "lg", 18 + }, 19 + }, 20 + defaultVariants: { 21 + variant: "default", 22 + size: "md", 23 + }, 24 + }); 25 + 26 + export interface InputProps 27 + extends Omit<InputPrimitiveProps, "style" | "error">, 28 + VariantProps<typeof inputVariants> { 29 + label?: string; 30 + description?: string; 31 + error?: string; 32 + required?: boolean; 33 + leftAddon?: React.ReactNode; 34 + rightAddon?: React.ReactNode; 35 + containerStyle?: any; 36 + inputStyle?: any; 37 + } 38 + 39 + export const Input = forwardRef<any, InputProps>( 40 + ( 41 + { 42 + variant = "default", 43 + size = "md", 44 + label, 45 + description, 46 + error, 47 + required = false, 48 + leftAddon, 49 + rightAddon, 50 + disabled = false, 51 + containerStyle, 52 + inputStyle, 53 + ...props 54 + }, 55 + ref, 56 + ) => { 57 + const { theme } = useTheme(); 58 + const [isFocused, setIsFocused] = React.useState(false); 59 + const inputRef = React.useRef<any>(null); 60 + 61 + // Create dynamic styles based on theme 62 + const styles = React.useMemo(() => createStyles(theme), [theme]); 63 + 64 + // Get variant and size styles 65 + const containerStyles = React.useMemo(() => { 66 + const variantStyle = styles[`${variant}Container` as keyof typeof styles]; 67 + const sizeStyle = styles[`${size}Container` as keyof typeof styles]; 68 + const focusStyle = isFocused ? styles.focusedContainer : null; 69 + return [variantStyle, sizeStyle, focusStyle]; 70 + }, [variant, size, styles, isFocused]); 71 + 72 + const textStyles = React.useMemo(() => { 73 + const variantTextStyle = styles[`${variant}Input` as keyof typeof styles]; 74 + const sizeTextStyle = styles[`${size}Input` as keyof typeof styles]; 75 + return [variantTextStyle, sizeTextStyle]; 76 + }, [variant, size, styles]); 77 + 78 + const handleFocus = React.useCallback( 79 + (event: any) => { 80 + setIsFocused(true); 81 + if (props.onFocus) { 82 + props.onFocus(event); 83 + } 84 + }, 85 + [props.onFocus], 86 + ); 87 + 88 + const handleBlur = React.useCallback( 89 + (event: any) => { 90 + setIsFocused(false); 91 + if (props.onBlur) { 92 + props.onBlur(event); 93 + } 94 + }, 95 + [props.onBlur], 96 + ); 97 + 98 + const handleContainerPress = React.useCallback(() => { 99 + if (inputRef.current && !disabled) { 100 + inputRef.current.focus(); 101 + } 102 + }, [disabled]); 103 + 104 + const hasAddons = leftAddon || rightAddon; 105 + 106 + if (hasAddons) { 107 + return ( 108 + <InputPrimitive.Group> 109 + {label && ( 110 + <InputPrimitive.Label 111 + required={required} 112 + disabled={disabled} 113 + error={!!error} 114 + > 115 + {label} 116 + </InputPrimitive.Label> 117 + )} 118 + 119 + <TouchableWithoutFeedback onPress={handleContainerPress}> 120 + <InputPrimitive.Container 121 + focused={isFocused} 122 + error={!!error} 123 + disabled={disabled} 124 + style={[containerStyles, containerStyle, { padding: 0 }]} 125 + > 126 + {leftAddon && ( 127 + <InputPrimitive.Addon position="left"> 128 + {leftAddon} 129 + </InputPrimitive.Addon> 130 + )} 131 + 132 + <InputPrimitive.Root 133 + ref={(node) => { 134 + inputRef.current = node; 135 + if (ref) { 136 + if (typeof ref === "function") { 137 + ref(node); 138 + } else { 139 + ref.current = node; 140 + } 141 + } 142 + }} 143 + disabled={disabled} 144 + error={!!error} 145 + onFocus={handleFocus} 146 + onBlur={handleBlur} 147 + style={[ 148 + textStyles, 149 + styles.inputInContainer, 150 + inputStyle, 151 + { outline: "none" }, 152 + ]} 153 + placeholderTextColor={ 154 + disabled ? theme.colors.textDisabled : theme.colors.textMuted 155 + } 156 + {...props} 157 + /> 158 + 159 + {rightAddon && ( 160 + <InputPrimitive.Addon position="right"> 161 + {rightAddon} 162 + </InputPrimitive.Addon> 163 + )} 164 + </InputPrimitive.Container> 165 + </TouchableWithoutFeedback> 166 + 167 + {description && !error && ( 168 + <InputPrimitive.Description disabled={disabled}> 169 + {description} 170 + </InputPrimitive.Description> 171 + )} 172 + 173 + <InputPrimitive.Error visible={!!error}>{error}</InputPrimitive.Error> 174 + </InputPrimitive.Group> 175 + ); 176 + } 177 + 178 + return ( 179 + <InputPrimitive.Group> 180 + {label && ( 181 + <InputPrimitive.Label 182 + required={required} 183 + disabled={disabled} 184 + error={!!error} 185 + > 186 + {label} 187 + </InputPrimitive.Label> 188 + )} 189 + 190 + <InputPrimitive.Root 191 + ref={(node) => { 192 + inputRef.current = node; 193 + if (ref) { 194 + if (typeof ref === "function") { 195 + ref(node); 196 + } else { 197 + ref.current = node; 198 + } 199 + } 200 + }} 201 + disabled={disabled} 202 + error={!!error} 203 + onFocus={handleFocus} 204 + onBlur={handleBlur} 205 + style={[containerStyles, textStyles, containerStyle, inputStyle]} 206 + placeholderTextColor={ 207 + disabled ? theme.colors.textDisabled : theme.colors.textMuted 208 + } 209 + {...props} 210 + /> 211 + 212 + {description && !error && ( 213 + <InputPrimitive.Description disabled={disabled}> 214 + {description} 215 + </InputPrimitive.Description> 216 + )} 217 + 218 + <InputPrimitive.Error visible={!!error}>{error}</InputPrimitive.Error> 219 + </InputPrimitive.Group> 220 + ); 221 + }, 222 + ); 223 + 224 + Input.displayName = "Input"; 225 + 226 + // Create theme-aware styles 227 + function createStyles(theme: any) { 228 + return StyleSheet.create({ 229 + // Variant styles for containers 230 + defaultContainer: { 231 + backgroundColor: theme.colors.background, 232 + borderWidth: 1, 233 + borderColor: theme.colors.border, 234 + borderRadius: theme.borderRadius.md, 235 + }, 236 + 237 + filledContainer: { 238 + backgroundColor: theme.colors.muted, 239 + borderWidth: 0, 240 + borderRadius: theme.borderRadius.md, 241 + }, 242 + 243 + underlinedContainer: { 244 + backgroundColor: "transparent", 245 + borderWidth: 0, 246 + borderBottomWidth: 1, 247 + borderBottomColor: theme.colors.border, 248 + borderRadius: 0, 249 + paddingHorizontal: 0, 250 + }, 251 + 252 + // Variant styles for inputs 253 + defaultInput: { 254 + color: theme.colors.text, 255 + backgroundColor: "transparent", 256 + }, 257 + 258 + filledInput: { 259 + color: theme.colors.text, 260 + backgroundColor: "transparent", 261 + }, 262 + 263 + underlinedInput: { 264 + color: theme.colors.text, 265 + backgroundColor: "transparent", 266 + }, 267 + 268 + // Size styles for containers 269 + smContainer: { 270 + paddingHorizontal: theme.spacing[3], 271 + paddingVertical: theme.spacing[2], 272 + minHeight: theme.touchTargets.minimum - 8, 273 + }, 274 + 275 + mdContainer: { 276 + paddingHorizontal: theme.spacing[3], 277 + paddingVertical: theme.spacing[3], 278 + minHeight: theme.touchTargets.minimum, 279 + }, 280 + 281 + lgContainer: { 282 + paddingHorizontal: theme.spacing[4], 283 + paddingVertical: theme.spacing[4], 284 + minHeight: theme.touchTargets.comfortable, 285 + }, 286 + 287 + // Size styles for inputs 288 + smInput: { 289 + fontSize: 14, 290 + lineHeight: 18, 291 + ...Platform.select({ 292 + ios: { 293 + paddingVertical: 0, 294 + }, 295 + android: { 296 + paddingVertical: 0, 297 + textAlignVertical: "center", 298 + }, 299 + }), 300 + }, 301 + 302 + mdInput: { 303 + fontSize: 16, 304 + lineHeight: 20, 305 + ...Platform.select({ 306 + ios: { 307 + paddingVertical: 0, 308 + }, 309 + android: { 310 + paddingVertical: 0, 311 + textAlignVertical: "center", 312 + }, 313 + }), 314 + }, 315 + 316 + lgInput: { 317 + fontSize: 18, 318 + lineHeight: 22, 319 + ...Platform.select({ 320 + ios: { 321 + paddingVertical: 0, 322 + }, 323 + android: { 324 + paddingVertical: 0, 325 + textAlignVertical: "center", 326 + }, 327 + }), 328 + }, 329 + 330 + // Special style for inputs inside containers 331 + inputInContainer: { 332 + flex: 1, 333 + paddingHorizontal: 0, 334 + paddingVertical: 0, 335 + borderWidth: 0, 336 + backgroundColor: "transparent", 337 + minHeight: "auto", 338 + borderRadius: 0, 339 + }, 340 + 341 + // Focus styles 342 + focusedContainer: { 343 + borderColor: theme.colors.primary, 344 + borderWidth: 1, 345 + }, 346 + }); 347 + } 348 + 349 + // Export input variants for external use 350 + export { inputVariants };
+292
js/components/src/components/ui/primitives/button.tsx
··· 1 + import React, { forwardRef } from "react"; 2 + import { 3 + AccessibilityRole, 4 + GestureResponderEvent, 5 + StyleSheet, 6 + Text, 7 + TextProps, 8 + TouchableOpacity, 9 + TouchableOpacityProps, 10 + View, 11 + ViewProps, 12 + } from "react-native"; 13 + 14 + // Base button primitive interface 15 + export interface ButtonPrimitiveProps 16 + extends Omit<TouchableOpacityProps, "onPress"> { 17 + onPress?: (event: GestureResponderEvent) => void; 18 + disabled?: boolean; 19 + loading?: boolean; 20 + accessibilityRole?: AccessibilityRole; 21 + accessibilityLabel?: string; 22 + accessibilityHint?: string; 23 + testID?: string; 24 + } 25 + 26 + // Button root primitive - handles all touch interactions 27 + export const ButtonRoot = forwardRef< 28 + React.ComponentRef<typeof TouchableOpacity>, 29 + ButtonPrimitiveProps 30 + >( 31 + ( 32 + { 33 + children, 34 + disabled = false, 35 + loading = false, 36 + onPress, 37 + onPressIn, 38 + onPressOut, 39 + onLongPress, 40 + accessibilityRole = "button", 41 + accessibilityLabel, 42 + accessibilityHint, 43 + accessibilityState, 44 + testID, 45 + style, 46 + activeOpacity = 0.7, 47 + ...props 48 + }, 49 + ref, 50 + ) => { 51 + const handlePress = React.useCallback( 52 + (event: GestureResponderEvent) => { 53 + if (!disabled && !loading && onPress) { 54 + onPress(event); 55 + } 56 + }, 57 + [disabled, loading, onPress], 58 + ); 59 + 60 + const handlePressIn = React.useCallback( 61 + (event: GestureResponderEvent) => { 62 + if (!disabled && !loading && onPressIn) { 63 + onPressIn(event); 64 + } 65 + }, 66 + [disabled, loading, onPressIn], 67 + ); 68 + 69 + const handlePressOut = React.useCallback( 70 + (event: GestureResponderEvent) => { 71 + if (!disabled && !loading && onPressOut) { 72 + onPressOut(event); 73 + } 74 + }, 75 + [disabled, loading, onPressOut], 76 + ); 77 + 78 + const handleLongPress = React.useCallback( 79 + (event: GestureResponderEvent) => { 80 + if (!disabled && !loading && onLongPress) { 81 + onLongPress(event); 82 + } 83 + }, 84 + [disabled, loading, onLongPress], 85 + ); 86 + 87 + return ( 88 + <TouchableOpacity 89 + ref={ref} 90 + onPress={handlePress} 91 + onPressIn={handlePressIn} 92 + onPressOut={handlePressOut} 93 + onLongPress={handleLongPress} 94 + disabled={disabled || loading} 95 + activeOpacity={disabled || loading ? 1 : activeOpacity} 96 + accessibilityRole={accessibilityRole} 97 + accessibilityLabel={accessibilityLabel} 98 + accessibilityHint={accessibilityHint} 99 + accessibilityState={{ 100 + disabled: disabled || loading, 101 + busy: loading, 102 + ...accessibilityState, 103 + }} 104 + testID={testID} 105 + style={[ 106 + primitiveStyles.button, 107 + (disabled || loading) && primitiveStyles.disabled, 108 + style, 109 + ]} 110 + {...props} 111 + > 112 + {children} 113 + </TouchableOpacity> 114 + ); 115 + }, 116 + ); 117 + 118 + ButtonRoot.displayName = "ButtonRoot"; 119 + 120 + // Button text primitive 121 + export interface ButtonTextProps extends TextProps { 122 + disabled?: boolean; 123 + loading?: boolean; 124 + } 125 + 126 + export const ButtonText = forwardRef<Text, ButtonTextProps>( 127 + ({ children, disabled, loading, style, ...props }, ref) => { 128 + return ( 129 + <Text 130 + ref={ref} 131 + style={[ 132 + primitiveStyles.text, 133 + (disabled || loading) && primitiveStyles.textDisabled, 134 + style, 135 + ]} 136 + {...props} 137 + > 138 + {children} 139 + </Text> 140 + ); 141 + }, 142 + ); 143 + 144 + ButtonText.displayName = "ButtonText"; 145 + 146 + // Button icon primitive 147 + export interface ButtonIconProps extends ViewProps { 148 + position?: "left" | "right"; 149 + disabled?: boolean; 150 + loading?: boolean; 151 + } 152 + 153 + export const ButtonIcon = forwardRef<View, ButtonIconProps>( 154 + ( 155 + { children, position = "left", disabled, loading, style, ...props }, 156 + ref, 157 + ) => { 158 + return ( 159 + <View 160 + ref={ref} 161 + style={[ 162 + primitiveStyles.icon, 163 + (disabled || loading) && primitiveStyles.iconDisabled, 164 + style, 165 + ]} 166 + {...props} 167 + > 168 + {children} 169 + </View> 170 + ); 171 + }, 172 + ); 173 + 174 + ButtonIcon.displayName = "ButtonIcon"; 175 + 176 + // Button loading indicator primitive 177 + export interface ButtonLoadingProps extends ViewProps { 178 + visible?: boolean; 179 + } 180 + 181 + export const ButtonLoading = forwardRef<View, ButtonLoadingProps>( 182 + ({ children, visible = false, style, ...props }, ref) => { 183 + if (!visible) return null; 184 + 185 + return ( 186 + <View ref={ref} style={[primitiveStyles.loading, style]} {...props}> 187 + {children} 188 + </View> 189 + ); 190 + }, 191 + ); 192 + 193 + ButtonLoading.displayName = "ButtonLoading"; 194 + 195 + // Container for button content with flex layout 196 + export interface ButtonContentProps extends ViewProps { 197 + direction?: "row" | "column"; 198 + align?: "flex-start" | "center" | "flex-end"; 199 + justify?: 200 + | "flex-start" 201 + | "center" 202 + | "flex-end" 203 + | "space-between" 204 + | "space-around"; 205 + } 206 + 207 + export const ButtonContent = forwardRef<View, ButtonContentProps>( 208 + ( 209 + { 210 + children, 211 + direction = "row", 212 + align = "center", 213 + justify = "center", 214 + style, 215 + ...props 216 + }, 217 + ref, 218 + ) => { 219 + return ( 220 + <View 221 + ref={ref} 222 + style={[ 223 + primitiveStyles.content, 224 + { 225 + flexDirection: direction, 226 + alignItems: align, 227 + justifyContent: justify, 228 + }, 229 + style, 230 + ]} 231 + {...props} 232 + > 233 + {children} 234 + </View> 235 + ); 236 + }, 237 + ); 238 + 239 + ButtonContent.displayName = "ButtonContent"; 240 + 241 + // Primitive styles (minimal, unstyled) 242 + const primitiveStyles = StyleSheet.create({ 243 + button: { 244 + flexDirection: "row", 245 + alignItems: "center", 246 + justifyContent: "center", 247 + minHeight: 44, // iOS minimum touch target 248 + minWidth: 44, 249 + }, 250 + disabled: { 251 + opacity: 0.5, 252 + }, 253 + content: { 254 + flexDirection: "row", 255 + alignItems: "center", 256 + justifyContent: "center", 257 + flex: 1, 258 + }, 259 + text: { 260 + textAlign: "center" as const, 261 + }, 262 + textDisabled: { 263 + opacity: 0.5, 264 + }, 265 + icon: { 266 + alignItems: "center", 267 + justifyContent: "center", 268 + }, 269 + iconLeft: { 270 + marginRight: 8, 271 + }, 272 + iconRight: { 273 + marginLeft: 8, 274 + }, 275 + iconDisabled: { 276 + opacity: 0.5, 277 + }, 278 + loading: { 279 + position: "absolute", 280 + alignItems: "center", 281 + justifyContent: "center", 282 + }, 283 + }); 284 + 285 + // Export primitive collection 286 + export const ButtonPrimitive = { 287 + Root: ButtonRoot, 288 + Text: ButtonText, 289 + Icon: ButtonIcon, 290 + Loading: ButtonLoading, 291 + Content: ButtonContent, 292 + };
+422
js/components/src/components/ui/primitives/input.tsx
··· 1 + import React, { forwardRef } from "react"; 2 + import { 3 + NativeSyntheticEvent, 4 + Platform, 5 + StyleSheet, 6 + Text, 7 + TextInput, 8 + TextInputFocusEventData, 9 + TextInputProps, 10 + TextProps, 11 + TouchableOpacity, 12 + View, 13 + ViewProps, 14 + } from "react-native"; 15 + 16 + // Base input primitive interface 17 + export interface InputPrimitiveProps extends Omit<TextInputProps, "onChange"> { 18 + error?: boolean; 19 + disabled?: boolean; 20 + loading?: boolean; 21 + onChange?: (text: string) => void; 22 + onFocus?: (event: NativeSyntheticEvent<TextInputFocusEventData>) => void; 23 + onBlur?: (event: NativeSyntheticEvent<TextInputFocusEventData>) => void; 24 + } 25 + 26 + // Input root primitive - the main TextInput component 27 + export const InputRoot = forwardRef<TextInput, InputPrimitiveProps>( 28 + ( 29 + { 30 + value, 31 + onChangeText, 32 + onChange, 33 + onFocus, 34 + onBlur, 35 + error = false, 36 + disabled = false, 37 + loading = false, 38 + editable, 39 + style, 40 + placeholderTextColor = "#9ca3af", 41 + ...props 42 + }, 43 + ref, 44 + ) => { 45 + const [isFocused, setIsFocused] = React.useState(false); 46 + 47 + const handleChangeText = React.useCallback( 48 + (text: string) => { 49 + if (onChangeText) { 50 + onChangeText(text); 51 + } 52 + if (onChange) { 53 + onChange(text); 54 + } 55 + }, 56 + [onChangeText, onChange], 57 + ); 58 + 59 + const handleFocus = React.useCallback( 60 + (event: NativeSyntheticEvent<TextInputFocusEventData>) => { 61 + setIsFocused(true); 62 + if (onFocus) { 63 + onFocus(event); 64 + } 65 + }, 66 + [onFocus], 67 + ); 68 + 69 + const handleBlur = React.useCallback( 70 + (event: NativeSyntheticEvent<TextInputFocusEventData>) => { 71 + setIsFocused(false); 72 + if (onBlur) { 73 + onBlur(event); 74 + } 75 + }, 76 + [onBlur], 77 + ); 78 + 79 + return ( 80 + <TextInput 81 + ref={ref} 82 + value={value} 83 + onChangeText={handleChangeText} 84 + onFocus={handleFocus} 85 + onBlur={handleBlur} 86 + editable={!disabled && !loading && editable} 87 + placeholderTextColor={placeholderTextColor} 88 + style={[ 89 + primitiveStyles.input, 90 + style, 91 + error && primitiveStyles.inputError, 92 + disabled && primitiveStyles.inputDisabled, 93 + loading && primitiveStyles.inputLoading, 94 + ]} 95 + {...props} 96 + /> 97 + ); 98 + }, 99 + ); 100 + 101 + InputRoot.displayName = "InputRoot"; 102 + 103 + // Input container primitive - wraps input with additional elements 104 + export interface InputContainerProps extends ViewProps { 105 + focused?: boolean; 106 + error?: boolean; 107 + disabled?: boolean; 108 + } 109 + 110 + export const InputContainer = forwardRef<View, InputContainerProps>( 111 + ( 112 + { 113 + children, 114 + focused = false, 115 + error = false, 116 + disabled = false, 117 + style, 118 + ...props 119 + }, 120 + ref, 121 + ) => { 122 + return ( 123 + <View 124 + ref={ref} 125 + style={[ 126 + primitiveStyles.container, 127 + style, 128 + focused && primitiveStyles.containerFocused, 129 + error && primitiveStyles.containerError, 130 + disabled && primitiveStyles.containerDisabled, 131 + ]} 132 + {...props} 133 + > 134 + {children} 135 + </View> 136 + ); 137 + }, 138 + ); 139 + 140 + InputContainer.displayName = "InputContainer"; 141 + 142 + // Input label primitive 143 + export interface InputLabelProps extends TextProps { 144 + required?: boolean; 145 + disabled?: boolean; 146 + error?: boolean; 147 + } 148 + 149 + export const InputLabel = forwardRef<Text, InputLabelProps>( 150 + ( 151 + { 152 + children, 153 + required = false, 154 + disabled = false, 155 + error = false, 156 + style, 157 + ...props 158 + }, 159 + ref, 160 + ) => { 161 + return ( 162 + <Text 163 + ref={ref} 164 + style={[ 165 + primitiveStyles.label, 166 + style, 167 + error && primitiveStyles.labelError, 168 + disabled && primitiveStyles.labelDisabled, 169 + ]} 170 + {...props} 171 + > 172 + {children} 173 + {required && <Text style={primitiveStyles.required}> *</Text>} 174 + </Text> 175 + ); 176 + }, 177 + ); 178 + 179 + InputLabel.displayName = "InputLabel"; 180 + 181 + // Input description/helper text primitive 182 + export interface InputDescriptionProps extends TextProps { 183 + error?: boolean; 184 + disabled?: boolean; 185 + } 186 + 187 + export const InputDescription = forwardRef<Text, InputDescriptionProps>( 188 + ({ children, error = false, disabled = false, style, ...props }, ref) => { 189 + return ( 190 + <Text 191 + ref={ref} 192 + style={[ 193 + primitiveStyles.description, 194 + style, 195 + error && primitiveStyles.descriptionError, 196 + disabled && primitiveStyles.descriptionDisabled, 197 + ]} 198 + {...props} 199 + > 200 + {children} 201 + </Text> 202 + ); 203 + }, 204 + ); 205 + 206 + InputDescription.displayName = "InputDescription"; 207 + 208 + // Input error message primitive 209 + export interface InputErrorProps extends TextProps { 210 + visible?: boolean; 211 + } 212 + 213 + export const InputError = forwardRef<Text, InputErrorProps>( 214 + ({ children, visible = true, style, ...props }, ref) => { 215 + if (!visible || !children) return null; 216 + 217 + return ( 218 + <Text ref={ref} style={[primitiveStyles.error, style]} {...props}> 219 + {children} 220 + </Text> 221 + ); 222 + }, 223 + ); 224 + 225 + InputError.displayName = "InputError"; 226 + 227 + // Input addon primitive (for icons, buttons, etc.) 228 + export interface InputAddonProps extends ViewProps { 229 + position?: "left" | "right"; 230 + touchable?: boolean; 231 + onPress?: () => void; 232 + } 233 + 234 + export const InputAddon = forwardRef< 235 + React.ComponentRef<typeof View> | React.ComponentRef<typeof TouchableOpacity>, 236 + InputAddonProps 237 + >( 238 + ( 239 + { 240 + children, 241 + position = "left", 242 + touchable = false, 243 + onPress, 244 + style, 245 + ...props 246 + }, 247 + ref, 248 + ) => { 249 + const addonStyle = [ 250 + primitiveStyles.addon, 251 + primitiveStyles[ 252 + `addon${position.charAt(0).toUpperCase() + position.slice(1)}` as keyof typeof primitiveStyles 253 + ], 254 + style, 255 + ]; 256 + 257 + if (touchable && onPress) { 258 + return ( 259 + <TouchableOpacity 260 + ref={ref as React.Ref<React.ComponentRef<typeof TouchableOpacity>>} 261 + style={addonStyle as any} 262 + onPress={onPress} 263 + {...props} 264 + > 265 + {children} 266 + </TouchableOpacity> 267 + ); 268 + } 269 + 270 + return ( 271 + <View 272 + ref={ref as React.Ref<React.ComponentRef<typeof View>>} 273 + style={addonStyle as any} 274 + {...props} 275 + > 276 + {children} 277 + </View> 278 + ); 279 + }, 280 + ); 281 + 282 + InputAddon.displayName = "InputAddon"; 283 + 284 + // Input group primitive - groups label, input, description, error 285 + export interface InputGroupProps extends ViewProps { 286 + spacing?: number; 287 + } 288 + 289 + export const InputGroup = forwardRef<View, InputGroupProps>( 290 + ({ children, spacing = 8, style, ...props }, ref) => { 291 + return ( 292 + <View 293 + ref={ref} 294 + style={[primitiveStyles.group, { gap: spacing }, style]} 295 + {...props} 296 + > 297 + {children} 298 + </View> 299 + ); 300 + }, 301 + ); 302 + 303 + InputGroup.displayName = "InputGroup"; 304 + 305 + // Primitive styles (minimal, unstyled) 306 + const primitiveStyles = StyleSheet.create({ 307 + input: { 308 + minHeight: 44, // iOS minimum touch target 309 + paddingHorizontal: 12, 310 + paddingVertical: 8, 311 + fontSize: 16, 312 + borderWidth: 1, 313 + borderColor: "#d1d5db", 314 + borderRadius: 8, 315 + backgroundColor: "white", 316 + ...Platform.select({ 317 + ios: { 318 + paddingVertical: 12, 319 + }, 320 + android: { 321 + paddingVertical: 8, 322 + textAlignVertical: "center", 323 + }, 324 + }), 325 + }, 326 + inputFocused: { 327 + // No focus styles for the actual input 328 + }, 329 + inputError: { 330 + borderColor: "#ef4444", 331 + borderWidth: 1, 332 + }, 333 + inputDisabled: { 334 + backgroundColor: "#f3f4f6", 335 + borderColor: "#e5e7eb", 336 + opacity: 0.6, 337 + }, 338 + inputLoading: { 339 + opacity: 0.7, 340 + }, 341 + container: { 342 + flexDirection: "row", 343 + alignItems: "center", 344 + borderWidth: 1, 345 + borderColor: "#d1d5db", 346 + borderRadius: 8, 347 + backgroundColor: "white", 348 + paddingHorizontal: 12, 349 + minHeight: 44, 350 + }, 351 + containerFocused: { 352 + borderColor: "#3b82f6", 353 + borderWidth: 1, 354 + }, 355 + containerError: { 356 + borderColor: "#ef4444", 357 + borderWidth: 1, 358 + }, 359 + containerDisabled: { 360 + backgroundColor: "#f3f4f6", 361 + borderColor: "#e5e7eb", 362 + opacity: 0.6, 363 + }, 364 + label: { 365 + fontSize: 14, 366 + fontWeight: "500", 367 + color: "#374151", 368 + marginBottom: 4, 369 + }, 370 + labelError: { 371 + color: "#ef4444", 372 + }, 373 + labelDisabled: { 374 + color: "#9ca3af", 375 + opacity: 0.6, 376 + }, 377 + required: { 378 + color: "#ef4444", 379 + }, 380 + description: { 381 + fontSize: 12, 382 + color: "#6b7280", 383 + marginTop: 4, 384 + }, 385 + descriptionError: { 386 + color: "#ef4444", 387 + }, 388 + descriptionDisabled: { 389 + color: "#9ca3af", 390 + opacity: 0.6, 391 + }, 392 + error: { 393 + fontSize: 12, 394 + color: "#ef4444", 395 + marginTop: 4, 396 + }, 397 + addon: { 398 + alignItems: "center", 399 + justifyContent: "center", 400 + paddingHorizontal: 8, 401 + }, 402 + addonLeft: { 403 + marginRight: 8, 404 + }, 405 + addonRight: { 406 + marginLeft: 8, 407 + }, 408 + group: { 409 + flexDirection: "column", 410 + }, 411 + }); 412 + 413 + // Export primitive collection 414 + export const InputPrimitive = { 415 + Root: InputRoot, 416 + Container: InputContainer, 417 + Label: InputLabel, 418 + Description: InputDescription, 419 + Error: InputError, 420 + Addon: InputAddon, 421 + Group: InputGroup, 422 + };
+421
js/components/src/components/ui/primitives/modal.tsx
··· 1 + import React, { forwardRef } from "react"; 2 + import { 3 + Dimensions, 4 + GestureResponderEvent, 5 + Modal, 6 + ModalProps, 7 + Platform, 8 + ScrollView, 9 + ScrollViewProps, 10 + StyleSheet, 11 + TouchableOpacity, 12 + TouchableOpacityProps, 13 + View, 14 + ViewProps, 15 + } from "react-native"; 16 + 17 + const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); 18 + 19 + // Base modal primitive interface 20 + export interface ModalPrimitiveProps extends Omit<ModalProps, "children"> { 21 + open?: boolean; 22 + onOpenChange?: (open: boolean) => void; 23 + children?: React.ReactNode; 24 + } 25 + 26 + // Modal root primitive - handles the native Modal component 27 + export const ModalRoot = forwardRef<View, ModalPrimitiveProps>( 28 + ( 29 + { 30 + open = false, 31 + onOpenChange, 32 + children, 33 + onRequestClose, 34 + animationType = "fade", 35 + presentationStyle = Platform.OS === "ios" ? "pageSheet" : "fullScreen", 36 + transparent = true, 37 + statusBarTranslucent = Platform.OS === "android", 38 + ...props 39 + }, 40 + ref, 41 + ) => { 42 + const handleRequestClose = React.useCallback( 43 + (e: any) => { 44 + if (onOpenChange) { 45 + onOpenChange(false); 46 + } 47 + if (onRequestClose) { 48 + onRequestClose(e); 49 + } 50 + }, 51 + [onOpenChange, onRequestClose], 52 + ); 53 + 54 + return ( 55 + <Modal 56 + visible={open} 57 + onRequestClose={handleRequestClose} 58 + animationType={animationType} 59 + presentationStyle={presentationStyle} 60 + transparent={transparent} 61 + statusBarTranslucent={statusBarTranslucent} 62 + {...props} 63 + > 64 + <View ref={ref} style={primitiveStyles.container}> 65 + {children} 66 + </View> 67 + </Modal> 68 + ); 69 + }, 70 + ); 71 + 72 + ModalRoot.displayName = "ModalRoot"; 73 + 74 + // Modal overlay primitive - semi-transparent background 75 + export interface ModalOverlayProps extends TouchableOpacityProps { 76 + dismissible?: boolean; 77 + onDismiss?: () => void; 78 + } 79 + 80 + export const ModalOverlay = forwardRef< 81 + React.ElementRef<typeof TouchableOpacity>, 82 + ModalOverlayProps 83 + >( 84 + ( 85 + { 86 + dismissible = true, 87 + onDismiss, 88 + onPress, 89 + style, 90 + children, 91 + activeOpacity = 1, 92 + ...props 93 + }, 94 + ref, 95 + ) => { 96 + const handlePress = React.useCallback( 97 + (event: GestureResponderEvent) => { 98 + if (dismissible && onDismiss) { 99 + onDismiss(); 100 + } 101 + if (onPress) { 102 + onPress(event); 103 + } 104 + }, 105 + [dismissible, onDismiss, onPress], 106 + ); 107 + 108 + return ( 109 + <TouchableOpacity 110 + ref={ref} 111 + style={[primitiveStyles.overlay, style]} 112 + activeOpacity={activeOpacity} 113 + onPress={handlePress} 114 + {...props} 115 + > 116 + {children} 117 + </TouchableOpacity> 118 + ); 119 + }, 120 + ); 121 + 122 + ModalOverlay.displayName = "ModalOverlay"; 123 + 124 + // Modal content primitive - the actual content container 125 + export interface ModalContentProps extends ViewProps { 126 + position?: "center" | "top" | "bottom" | "left" | "right"; 127 + size?: "sm" | "md" | "lg" | "xl" | "full"; 128 + } 129 + 130 + export const ModalContent = forwardRef<View, ModalContentProps>( 131 + ( 132 + { 133 + children, 134 + position = "center", 135 + size = "md", 136 + style, 137 + onStartShouldSetResponder, 138 + ...props 139 + }, 140 + ref, 141 + ) => { 142 + // Prevent touches from propagating to overlay 143 + const handleStartShouldSetResponder = React.useCallback(() => { 144 + return true; 145 + }, []); 146 + 147 + const positionStyle = React.useMemo(() => { 148 + switch (position) { 149 + case "top": 150 + return primitiveStyles.contentTop; 151 + case "bottom": 152 + return primitiveStyles.contentBottom; 153 + case "left": 154 + return primitiveStyles.contentLeft; 155 + case "right": 156 + return primitiveStyles.contentRight; 157 + case "center": 158 + default: 159 + return primitiveStyles.contentCenter; 160 + } 161 + }, [position]); 162 + 163 + const sizeStyle = React.useMemo(() => { 164 + switch (size) { 165 + case "sm": 166 + return primitiveStyles.sizeSm; 167 + case "lg": 168 + return primitiveStyles.sizeLg; 169 + case "xl": 170 + return primitiveStyles.sizeXl; 171 + case "full": 172 + return primitiveStyles.sizeFull; 173 + case "md": 174 + default: 175 + return primitiveStyles.sizeMd; 176 + } 177 + }, [size]); 178 + 179 + return ( 180 + <View 181 + ref={ref} 182 + style={[primitiveStyles.content, positionStyle, sizeStyle, style]} 183 + onStartShouldSetResponder={ 184 + onStartShouldSetResponder || handleStartShouldSetResponder 185 + } 186 + {...props} 187 + > 188 + {children} 189 + </View> 190 + ); 191 + }, 192 + ); 193 + 194 + ModalContent.displayName = "ModalContent"; 195 + 196 + // Modal header primitive 197 + export interface ModalHeaderProps extends ViewProps { 198 + withBorder?: boolean; 199 + } 200 + 201 + export const ModalHeader = forwardRef<View, ModalHeaderProps>( 202 + ({ children, withBorder = false, style, ...props }, ref) => { 203 + return ( 204 + <View 205 + ref={ref} 206 + style={[ 207 + primitiveStyles.header, 208 + withBorder && primitiveStyles.headerBorder, 209 + style, 210 + ]} 211 + {...props} 212 + > 213 + {children} 214 + </View> 215 + ); 216 + }, 217 + ); 218 + 219 + ModalHeader.displayName = "ModalHeader"; 220 + 221 + // Modal body primitive - scrollable content area 222 + export interface ModalBodyProps extends ScrollViewProps { 223 + scrollable?: boolean; 224 + } 225 + 226 + export const ModalBody = forwardRef<ScrollView, ModalBodyProps>( 227 + ({ children, scrollable = true, style, ...props }, ref) => { 228 + if (!scrollable) { 229 + return <View style={[primitiveStyles.body, style]}>{children}</View>; 230 + } 231 + 232 + return ( 233 + <ScrollView 234 + ref={ref} 235 + style={[primitiveStyles.body, style]} 236 + showsVerticalScrollIndicator={false} 237 + keyboardShouldPersistTaps="handled" 238 + {...props} 239 + > 240 + {children} 241 + </ScrollView> 242 + ); 243 + }, 244 + ); 245 + 246 + ModalBody.displayName = "ModalBody"; 247 + 248 + // Modal footer primitive 249 + export interface ModalFooterProps extends ViewProps { 250 + withBorder?: boolean; 251 + direction?: "row" | "column"; 252 + justify?: 253 + | "flex-start" 254 + | "center" 255 + | "flex-end" 256 + | "space-between" 257 + | "space-around"; 258 + } 259 + 260 + export const ModalFooter = forwardRef<View, ModalFooterProps>( 261 + ( 262 + { 263 + children, 264 + withBorder = false, 265 + direction = "row", 266 + justify = "flex-end", 267 + style, 268 + ...props 269 + }, 270 + ref, 271 + ) => { 272 + return ( 273 + <View 274 + ref={ref} 275 + style={[ 276 + primitiveStyles.footer, 277 + withBorder && primitiveStyles.footerBorder, 278 + { 279 + flexDirection: direction, 280 + justifyContent: justify, 281 + }, 282 + style, 283 + ]} 284 + {...props} 285 + > 286 + {children} 287 + </View> 288 + ); 289 + }, 290 + ); 291 + 292 + ModalFooter.displayName = "ModalFooter"; 293 + 294 + // Modal close trigger primitive 295 + export interface ModalCloseProps extends TouchableOpacityProps { 296 + onClose?: () => void; 297 + } 298 + 299 + export const ModalClose = forwardRef< 300 + React.ElementRef<typeof TouchableOpacity>, 301 + ModalCloseProps 302 + >(({ children, onClose, onPress, ...props }, ref) => { 303 + const handlePress = React.useCallback( 304 + (event: GestureResponderEvent) => { 305 + if (onClose) { 306 + onClose(); 307 + } 308 + if (onPress) { 309 + onPress(event); 310 + } 311 + }, 312 + [onClose, onPress], 313 + ); 314 + 315 + return ( 316 + <TouchableOpacity ref={ref} onPress={handlePress} {...props}> 317 + {children} 318 + </TouchableOpacity> 319 + ); 320 + }); 321 + 322 + ModalClose.displayName = "ModalClose"; 323 + 324 + // Primitive styles (minimal, unstyled) 325 + const primitiveStyles = StyleSheet.create({ 326 + container: { 327 + flex: 1, 328 + }, 329 + overlay: { 330 + flex: 1, 331 + backgroundColor: "rgba(0, 0, 0, 0.5)", 332 + justifyContent: "center", 333 + alignItems: "center", 334 + padding: 16, 335 + }, 336 + content: { 337 + backgroundColor: "white", 338 + borderRadius: 8, 339 + overflow: "hidden", 340 + }, 341 + contentCenter: { 342 + alignSelf: "center", 343 + }, 344 + contentTop: { 345 + alignSelf: "center", 346 + marginTop: 0, 347 + }, 348 + contentBottom: { 349 + alignSelf: "center", 350 + marginTop: "auto", 351 + }, 352 + contentLeft: { 353 + alignSelf: "flex-start", 354 + marginRight: "auto", 355 + }, 356 + contentRight: { 357 + alignSelf: "flex-end", 358 + marginLeft: "auto", 359 + }, 360 + sizeSm: { 361 + maxWidth: screenWidth * 0.4, 362 + maxHeight: screenHeight * 0.6, 363 + }, 364 + sizeMd: { 365 + maxWidth: screenWidth * 0.6, 366 + maxHeight: screenHeight * 0.8, 367 + }, 368 + sizeLg: { 369 + maxWidth: screenWidth * 0.8, 370 + maxHeight: screenHeight * 0.9, 371 + }, 372 + sizeXl: { 373 + maxWidth: screenWidth * 0.95, 374 + maxHeight: screenHeight * 0.95, 375 + }, 376 + sizeFull: { 377 + width: screenWidth, 378 + height: screenHeight, 379 + maxWidth: screenWidth, 380 + maxHeight: screenHeight, 381 + borderRadius: 0, 382 + }, 383 + header: { 384 + paddingHorizontal: 16, 385 + paddingVertical: 12, 386 + flexDirection: "row", 387 + alignItems: "center", 388 + justifyContent: "space-between", 389 + }, 390 + headerBorder: { 391 + borderBottomWidth: 1, 392 + borderBottomColor: "#e5e7eb", 393 + }, 394 + body: { 395 + flex: 1, 396 + paddingHorizontal: 16, 397 + paddingVertical: 12, 398 + }, 399 + footer: { 400 + paddingHorizontal: 16, 401 + paddingVertical: 12, 402 + flexDirection: "row", 403 + alignItems: "center", 404 + gap: 8, 405 + }, 406 + footerBorder: { 407 + borderTopWidth: 1, 408 + borderTopColor: "#e5e7eb", 409 + }, 410 + }); 411 + 412 + // Export primitive collection 413 + export const ModalPrimitive = { 414 + Root: ModalRoot, 415 + Overlay: ModalOverlay, 416 + Content: ModalContent, 417 + Header: ModalHeader, 418 + Body: ModalBody, 419 + Footer: ModalFooter, 420 + Close: ModalClose, 421 + };
+499
js/components/src/components/ui/primitives/text.tsx
··· 1 + import { createContext, forwardRef, useContext } from "react"; 2 + import type { ColorValue as RNColorValue } from "react-native"; 3 + import { 4 + AnimatableNumericValue, 5 + ColorValue, 6 + OpaqueColorValue, 7 + Platform, 8 + Text as RNText, 9 + TextProps as RNTextProps, 10 + TextStyle, 11 + } from "react-native"; 12 + import { useTheme } from "../../../lib/theme/theme"; 13 + import { typography, type Typography } from "../../../lib/theme/tokens"; 14 + 15 + // Text inheritance context 16 + interface TextContextValue { 17 + fontSize?: number; 18 + fontWeight?: TextStyle["fontWeight"]; 19 + color?: string | RNColorValue | OpaqueColorValue; 20 + fontFamily?: string; 21 + lineHeight?: number; 22 + textAlign?: TextStyle["textAlign"]; 23 + letterSpacing?: number; 24 + textTransform?: TextStyle["textTransform"]; 25 + textDecorationLine?: TextStyle["textDecorationLine"]; 26 + fontStyle?: TextStyle["fontStyle"]; 27 + opacity?: number | AnimatableNumericValue; 28 + } 29 + 30 + export const TextContext = createContext<Partial<TextContextValue> | null>( 31 + null, 32 + ); 33 + 34 + export function objectFromObjects( 35 + arr: Record<string, any>[], 36 + ): Record<string, any> { 37 + return Object.assign({}, ...arr); 38 + } 39 + 40 + // Text primitive props 41 + export interface TextPrimitiveProps extends Omit<RNTextProps, "style"> { 42 + // Typography variants 43 + variant?: 44 + | "h1" 45 + | "h2" 46 + | "h3" 47 + | "h4" 48 + | "h5" 49 + | "h6" 50 + | "body1" 51 + | "body2" 52 + | "caption" 53 + | "overline" 54 + | "subtitle1" 55 + | "subtitle2"; 56 + 57 + // Size system 58 + size?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | number; 59 + 60 + // Weight system 61 + weight?: 62 + | "thin" 63 + | "light" 64 + | "normal" 65 + | "medium" 66 + | "semibold" 67 + | "bold" 68 + | "extrabold" 69 + | "black"; 70 + 71 + // Color variants 72 + color?: 73 + | "default" 74 + | "muted" 75 + | "primary" 76 + | "secondary" 77 + | "destructive" 78 + | "success" 79 + | "warning" 80 + | (string & {}); 81 + 82 + // Text alignment 83 + align?: "left" | "center" | "right" | "justify"; 84 + 85 + // Line height 86 + leading?: "none" | "tight" | "snug" | "normal" | "relaxed" | "loose" | number; 87 + 88 + // Letter spacing 89 + tracking?: 90 + | "tighter" 91 + | "tight" 92 + | "normal" 93 + | "wide" 94 + | "wider" 95 + | "widest" 96 + | number; 97 + 98 + // Text transform 99 + transform?: "none" | "capitalize" | "uppercase" | "lowercase"; 100 + 101 + // Text decoration 102 + decoration?: "none" | "underline" | "line-through"; 103 + 104 + // Font style 105 + italic?: boolean; 106 + 107 + // Opacity 108 + opacity?: number; 109 + 110 + // Custom style 111 + style?: TextStyle | TextStyle[]; 112 + 113 + // Inheritance - whether this component should inherit from parent context 114 + inherit?: boolean; 115 + 116 + // Reset inheritance - start fresh context 117 + reset?: boolean; 118 + } 119 + 120 + // Size mapping 121 + const sizeMap = { 122 + xs: 12, 123 + sm: 14, 124 + base: 16, 125 + lg: 18, 126 + xl: 20, 127 + "2xl": 24, 128 + "3xl": 30, 129 + "4xl": 36, 130 + } as const; 131 + 132 + // Weight mapping 133 + const weightMap = { 134 + thin: "100", 135 + light: "300", 136 + normal: "400", 137 + medium: "500", 138 + semibold: "600", 139 + bold: "700", 140 + extrabold: "800", 141 + black: "900", 142 + } as const; 143 + 144 + // Line height mapping 145 + const leadingMap = { 146 + none: 1, 147 + tight: 1.2, 148 + snug: 1.3, 149 + normal: 1.5, 150 + relaxed: 1.7, 151 + loose: 2, 152 + } as const; 153 + 154 + // Letter spacing mapping 155 + const trackingMap = { 156 + tighter: -0.8, 157 + tight: -0.4, 158 + normal: 0, 159 + wide: 0.4, 160 + wider: 0.8, 161 + widest: 1.6, 162 + } as const; 163 + 164 + // Variant definitions (platform-aware) 165 + const getVariantStyles = () => { 166 + // get platform-specific typography 167 + // iOS, Android, Web (Universal) 168 + const typographicPlatform = ( 169 + Platform.OS === "ios" 170 + ? "ios" 171 + : Platform.OS === "android" 172 + ? "android" 173 + : "universal" 174 + ) as keyof Typography; 175 + const platformTypography = typography[typographicPlatform] as Record< 176 + string, 177 + TextStyle 178 + >; 179 + 180 + if (!platformTypography) { 181 + throw new Error("Platform typography not defined"); 182 + } 183 + 184 + // Define mapping based on platform 185 + if (typographicPlatform === "ios") { 186 + return { 187 + h1: platformTypography.largeTitle, 188 + h2: platformTypography.title1, 189 + h3: platformTypography.title2, 190 + h4: platformTypography.title3, 191 + h5: platformTypography.headline, 192 + h6: platformTypography.headline, 193 + subtitle1: platformTypography.subhead, 194 + subtitle2: platformTypography.footnote, 195 + body1: platformTypography.body, 196 + body2: platformTypography.callout, 197 + caption: platformTypography.caption1, 198 + overline: platformTypography.caption2, 199 + }; 200 + } else if (typographicPlatform === "android") { 201 + return { 202 + h1: platformTypography.headline1, 203 + h2: platformTypography.headline2, 204 + h3: platformTypography.headline3, 205 + h4: platformTypography.headline4, 206 + h5: platformTypography.headline5, 207 + h6: platformTypography.headline6, 208 + subtitle1: platformTypography.subtitle1, 209 + subtitle2: platformTypography.subtitle2, 210 + body1: platformTypography.body1, 211 + body2: platformTypography.body2, 212 + caption: platformTypography.caption, 213 + overline: platformTypography.overline, 214 + }; 215 + } else { 216 + // universal 217 + // Map variants to universal sizes 218 + return { 219 + h1: platformTypography["4xl"], 220 + h2: platformTypography["3xl"], 221 + h3: platformTypography["2xl"], 222 + h4: platformTypography["xl"], 223 + h5: platformTypography["lg"], 224 + h6: platformTypography["base"], 225 + subtitle1: platformTypography.base, 226 + subtitle2: platformTypography.sm, 227 + body1: platformTypography.base, 228 + body2: platformTypography.sm, 229 + caption: platformTypography.xs, 230 + overline: platformTypography.xs, 231 + }; 232 + } 233 + }; 234 + 235 + // Text root primitive 236 + export const TextRoot = forwardRef<RNText, TextPrimitiveProps>( 237 + ( 238 + { 239 + variant, 240 + size, 241 + weight, 242 + color, 243 + align, 244 + leading, 245 + tracking, 246 + transform, 247 + decoration, 248 + italic = false, 249 + opacity, 250 + style, 251 + inherit = true, 252 + reset = false, 253 + children, 254 + ...props 255 + }, 256 + ref, 257 + ) => { 258 + const { theme } = useTheme(); 259 + const parentContext = useContext(TextContext); 260 + 261 + // Get variant styles 262 + const variantStyles = getVariantStyles() as Record<string, TextStyle>; 263 + 264 + // Calculate inherited values 265 + const inheritedContext = 266 + inherit && !reset && parentContext ? parentContext : {}; 267 + 268 + // Calculate final styles 269 + const finalStyles: TextStyle = { 270 + // Start with inherited values 271 + fontSize: inheritedContext.fontSize, 272 + fontWeight: inheritedContext.fontWeight, 273 + //color: inheritedContext.color, 274 + fontFamily: inheritedContext.fontFamily, 275 + lineHeight: inheritedContext.lineHeight, 276 + textAlign: inheritedContext.textAlign, 277 + letterSpacing: inheritedContext.letterSpacing, 278 + textTransform: inheritedContext.textTransform, 279 + textDecorationLine: 280 + inheritedContext.textDecorationLine as TextStyle["textDecorationLine"], 281 + fontStyle: inheritedContext.fontStyle, 282 + opacity: inheritedContext.opacity, 283 + 284 + // Apply variant styles (these may override inherited) 285 + ...(variant && variantStyles[variant]), 286 + 287 + // Apply explicit prop styles (these should override inherited and variant) 288 + 289 + // Apply size 290 + ...(size && { 291 + fontSize: typeof size === "number" ? size : sizeMap[size], 292 + }), 293 + 294 + // Apply weight 295 + ...(weight && { 296 + fontWeight: weightMap[weight] as TextStyle["fontWeight"], 297 + }), 298 + 299 + // Apply color 300 + ...(color 301 + ? { 302 + color: 303 + color === "default" 304 + ? theme.colors.text 305 + : color === "muted" 306 + ? theme.colors.textMuted 307 + : color === "primary" 308 + ? theme.colors.primary 309 + : color === "secondary" 310 + ? theme.colors.secondary 311 + : color === "destructive" 312 + ? theme.colors.destructive 313 + : color === "success" 314 + ? theme.colors.success 315 + : color === "warning" 316 + ? theme.colors.warning 317 + : color || inheritedContext.color, // Custom color string 318 + } 319 + : { color: inheritedContext.color || theme.colors.text }), 320 + 321 + // Apply alignment 322 + ...(align && { 323 + textAlign: align, 324 + }), 325 + 326 + // Apply line height 327 + ...(leading && { 328 + lineHeight: typeof leading === "number" ? leading : leadingMap[leading], 329 + }), 330 + 331 + // Apply letter spacing 332 + ...(tracking && { 333 + letterSpacing: 334 + typeof tracking === "number" ? tracking : trackingMap[tracking], 335 + }), 336 + 337 + // Apply text transform 338 + ...(transform && 339 + transform !== "none" && { 340 + textTransform: transform, 341 + }), 342 + 343 + // Apply text decoration 344 + ...(decoration && 345 + decoration !== "none" && { 346 + textDecorationLine: decoration, 347 + }), 348 + 349 + // Apply italic 350 + ...(italic && { 351 + fontStyle: "italic", 352 + }), 353 + 354 + // Apply opacity 355 + ...(opacity !== undefined && { 356 + opacity, 357 + }), 358 + }; 359 + 360 + finalStyles.color = finalStyles.color as ColorValue; 361 + 362 + // Create context value for children 363 + const contextValue: TextContextValue = { 364 + fontSize: 365 + typeof finalStyles.fontSize === "number" 366 + ? finalStyles.fontSize 367 + : undefined, 368 + fontWeight: finalStyles.fontWeight, 369 + color: finalStyles.color || undefined, 370 + fontFamily: 371 + typeof finalStyles.fontFamily === "string" 372 + ? finalStyles.fontFamily 373 + : undefined, 374 + lineHeight: 375 + typeof finalStyles.lineHeight === "number" 376 + ? finalStyles.lineHeight 377 + : undefined, 378 + textAlign: finalStyles.textAlign, 379 + letterSpacing: finalStyles.letterSpacing as number | undefined, 380 + textTransform: finalStyles.textTransform, 381 + textDecorationLine: 382 + finalStyles.textDecorationLine as TextStyle["textDecorationLine"], 383 + fontStyle: finalStyles.fontStyle, 384 + opacity: finalStyles.opacity as number | undefined, 385 + }; 386 + 387 + return ( 388 + <TextContext.Provider value={contextValue}> 389 + <RNText ref={ref} style={[finalStyles, style]} {...props}> 390 + {children} 391 + </RNText> 392 + </TextContext.Provider> 393 + ); 394 + }, 395 + ); 396 + 397 + TextRoot.displayName = "TextRoot"; 398 + 399 + // Text span primitive (inherits from parent but doesn't create new context) 400 + export const TextSpan = forwardRef<RNText, Omit<TextPrimitiveProps, "reset">>( 401 + ({ children, ...props }, ref) => { 402 + return ( 403 + <TextRoot ref={ref as any} inherit={true} {...props}> 404 + {children} 405 + </TextRoot> 406 + ); 407 + }, 408 + ); 409 + 410 + TextSpan.displayName = "TextSpan"; 411 + 412 + // Text block primitive (always creates new context) 413 + export const TextBlock = forwardRef<RNText, TextPrimitiveProps>( 414 + ({ children, reset = true, ...props }, ref) => { 415 + return ( 416 + <TextRoot ref={ref as any} reset={reset} {...props}> 417 + {children} 418 + </TextRoot> 419 + ); 420 + }, 421 + ); 422 + 423 + TextBlock.displayName = "TextBlock"; 424 + 425 + // Hook to access current text context 426 + export function useTextContext(): TextContextValue | null { 427 + return useContext(TextContext); 428 + } 429 + 430 + // Utility function to create text styles 431 + export function createTextStyle( 432 + props: Omit<TextPrimitiveProps, "children" | "style" | "ref">, 433 + ): TextStyle { 434 + // This is a utility function that can be used to generate styles 435 + // without rendering a component 436 + const style: TextStyle = {}; 437 + 438 + if (props.size) { 439 + style.fontSize = 440 + typeof props.size === "number" ? props.size : sizeMap[props.size]; 441 + } 442 + 443 + if (props.weight) { 444 + style.fontWeight = weightMap[props.weight] as TextStyle["fontWeight"]; 445 + } 446 + 447 + if (props.align) { 448 + style.textAlign = props.align; 449 + } 450 + 451 + if (props.leading) { 452 + style.lineHeight = 453 + typeof props.leading === "number" 454 + ? props.leading 455 + : leadingMap[props.leading]; 456 + } 457 + 458 + if (props.tracking) { 459 + style.letterSpacing = 460 + typeof props.tracking === "number" 461 + ? props.tracking 462 + : trackingMap[props.tracking]; 463 + } 464 + 465 + if (props.transform && props.transform !== "none") { 466 + style.textTransform = props.transform; 467 + } 468 + 469 + if (props.decoration && props.decoration !== "none") { 470 + style.textDecorationLine = props.decoration; 471 + } 472 + 473 + if (props.italic) { 474 + style.fontStyle = "italic"; 475 + } 476 + 477 + if (props.opacity !== undefined) { 478 + style.opacity = props.opacity; 479 + } 480 + 481 + return style; 482 + } 483 + 484 + // Export primitive collection 485 + export const TextPrimitive: { 486 + Root: typeof TextRoot; 487 + Span: typeof TextSpan; 488 + Block: typeof TextBlock; 489 + Context: typeof TextContext; 490 + useContext: typeof useTextContext; 491 + createStyle: typeof createTextStyle; 492 + } = { 493 + Root: TextRoot, 494 + Span: TextSpan, 495 + Block: TextBlock, 496 + Context: TextContext, 497 + useContext: useTextContext, 498 + createStyle: createTextStyle, 499 + };
+330
js/components/src/components/ui/text.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import React, { forwardRef } from "react"; 3 + import { StyleSheet } from "react-native"; 4 + import { colors, borderRadius as radius, spacing } from "../../lib/theme/atoms"; 5 + import { TextPrimitive, TextPrimitiveProps } from "./primitives/text"; 6 + 7 + // Text variants using class-variance-authority pattern 8 + const textVariants = cva("", { 9 + variants: { 10 + variant: { 11 + h1: "h1", 12 + h2: "h2", 13 + h3: "h3", 14 + h4: "h4", 15 + h5: "h5", 16 + h6: "h6", 17 + subtitle1: "subtitle1", 18 + subtitle2: "subtitle2", 19 + body1: "body1", 20 + body2: "body2", 21 + caption: "caption", 22 + overline: "overline", 23 + }, 24 + size: { 25 + xs: "xs", 26 + sm: "sm", 27 + base: "base", 28 + lg: "lg", 29 + xl: "xl", 30 + "2xl": "2xl", 31 + "3xl": "3xl", 32 + "4xl": "4xl", 33 + }, 34 + weight: { 35 + thin: "thin", 36 + light: "light", 37 + normal: "normal", 38 + medium: "medium", 39 + semibold: "semibold", 40 + bold: "bold", 41 + extrabold: "extrabold", 42 + black: "black", 43 + }, 44 + color: { 45 + default: "default", 46 + muted: "muted", 47 + primary: "primary", 48 + secondary: "secondary", 49 + destructive: "destructive", 50 + success: "success", 51 + warning: "warning", 52 + }, 53 + }, 54 + defaultVariants: { 55 + variant: "body1", 56 + size: "base", 57 + weight: "normal", 58 + color: "default", 59 + }, 60 + }); 61 + 62 + export interface TextProps 63 + extends Omit<TextPrimitiveProps, "variant" | "size" | "weight" | "color">, 64 + VariantProps<typeof textVariants> { 65 + // Additional convenience props 66 + muted?: boolean; 67 + bold?: boolean; 68 + italic?: boolean; 69 + underline?: boolean; 70 + strikethrough?: boolean; 71 + uppercase?: boolean; 72 + lowercase?: boolean; 73 + capitalize?: boolean; 74 + center?: boolean; 75 + right?: boolean; 76 + justify?: boolean; 77 + // Custom color override 78 + customColor?: string; 79 + } 80 + 81 + export const Text = forwardRef<any, TextProps>( 82 + ( 83 + { 84 + variant = undefined, 85 + size = undefined, 86 + weight = undefined, 87 + color = undefined, 88 + muted = false, 89 + bold = false, 90 + italic = false, 91 + underline = false, 92 + strikethrough = false, 93 + uppercase = false, 94 + lowercase = false, 95 + capitalize = false, 96 + center = false, 97 + right = false, 98 + justify = false, 99 + customColor, 100 + style, 101 + children, 102 + ...props 103 + }, 104 + ref, 105 + ) => { 106 + // Create dynamic styles based on atoms 107 + const styles = React.useMemo(() => createStyles(), []); 108 + 109 + // Override props based on convenience props 110 + const finalColor = customColor ? customColor : muted ? "muted" : color; 111 + 112 + const finalTransform = uppercase 113 + ? "uppercase" 114 + : lowercase 115 + ? "lowercase" 116 + : capitalize 117 + ? "capitalize" 118 + : "none"; 119 + 120 + const finalDecoration = 121 + underline && strikethrough 122 + ? "underline line-through" 123 + : underline 124 + ? "underline" 125 + : strikethrough 126 + ? "line-through" 127 + : "none"; 128 + 129 + const finalAlign = center 130 + ? "center" 131 + : right 132 + ? "right" 133 + : justify 134 + ? "justify" 135 + : "left"; 136 + 137 + // Get variant-specific styles 138 + const variantStyle = styles[`${variant}Style` as keyof typeof styles] || {}; 139 + 140 + const styleArr = ( 141 + Array.isArray(style) ? style : [style || undefined] 142 + ).filter((s) => s !== undefined); 143 + 144 + return ( 145 + <TextPrimitive.Root 146 + ref={ref} 147 + variant={variant || "body1"} 148 + size={size || "base"} 149 + color={finalColor || "default"} 150 + align={finalAlign} 151 + transform={finalTransform} 152 + decoration={finalDecoration as any} 153 + italic={italic} 154 + style={[variantStyle, ...styleArr]} 155 + {...props} 156 + > 157 + {children} 158 + </TextPrimitive.Root> 159 + ); 160 + }, 161 + ); 162 + 163 + Text.displayName = "Text"; 164 + 165 + // Convenience components for common text elements 166 + export const Heading = forwardRef< 167 + any, 168 + Omit<TextProps, "variant"> & { level?: 1 | 2 | 3 | 4 | 5 | 6 } 169 + >(({ level = 1, ...props }, ref) => ( 170 + <Text ref={ref} variant={`h${level}` as any} {...props} /> 171 + )); 172 + 173 + Heading.displayName = "Heading"; 174 + 175 + export const Subtitle = forwardRef< 176 + any, 177 + Omit<TextProps, "variant"> & { level?: 1 | 2 } 178 + >(({ level = 1, ...props }, ref) => ( 179 + <Text 180 + ref={ref} 181 + variant={level === 1 ? "subtitle1" : "subtitle2"} 182 + {...props} 183 + /> 184 + )); 185 + 186 + Subtitle.displayName = "Subtitle"; 187 + 188 + export const Body = forwardRef< 189 + any, 190 + Omit<TextProps, "variant"> & { level?: 1 | 2 } 191 + >(({ level = 1, ...props }, ref) => ( 192 + <Text ref={ref} variant={level === 1 ? "body1" : "body2"} {...props} /> 193 + )); 194 + 195 + Body.displayName = "Body"; 196 + 197 + export const Caption = forwardRef<any, Omit<TextProps, "variant">>( 198 + (props, ref) => <Text ref={ref} variant="caption" {...props} />, 199 + ); 200 + 201 + Caption.displayName = "Caption"; 202 + 203 + export const Label = forwardRef<any, Omit<TextProps, "variant">>( 204 + (props, ref) => ( 205 + <Text ref={ref} variant="subtitle1" weight="medium" {...props} /> 206 + ), 207 + ); 208 + 209 + Label.displayName = "Label"; 210 + 211 + export const Code = forwardRef<any, TextProps>(({ style, ...props }, ref) => { 212 + const styles = React.useMemo(() => createStyles(), []); 213 + // if style is not an array, convert it to an array 214 + const styleArr = (Array.isArray(style) ? style : [style || undefined]).filter( 215 + (s) => s !== undefined, 216 + ); 217 + 218 + return <Text ref={ref} style={[styles.codeStyle, ...styleArr]} {...props} />; 219 + }); 220 + 221 + Code.displayName = "Code"; 222 + 223 + // Span component for inline text styling (inherits from parent) 224 + export const Span = forwardRef< 225 + any, 226 + Omit<TextProps, "variant" | "size" | "weight" | "color"> & { 227 + variant?: 228 + | "h1" 229 + | "h2" 230 + | "h3" 231 + | "h4" 232 + | "h5" 233 + | "h6" 234 + | "subtitle1" 235 + | "subtitle2" 236 + | "body1" 237 + | "body2" 238 + | "caption" 239 + | "overline"; 240 + size?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl" | "4xl"; 241 + weight?: 242 + | "thin" 243 + | "light" 244 + | "normal" 245 + | "medium" 246 + | "semibold" 247 + | "bold" 248 + | "extrabold" 249 + | "black"; 250 + color?: 251 + | "default" 252 + | "muted" 253 + | "primary" 254 + | "secondary" 255 + | "destructive" 256 + | "success" 257 + | "warning"; 258 + } 259 + >(({ children, ...props }, ref) => ( 260 + <TextPrimitive.Span ref={ref} {...props}> 261 + {children} 262 + </TextPrimitive.Span> 263 + )); 264 + 265 + Span.displayName = "Span"; 266 + 267 + // Create atom-based styles 268 + function createStyles() { 269 + return StyleSheet.create({ 270 + // Variant-specific styles 271 + h1Style: { 272 + marginBottom: spacing[4], 273 + }, 274 + h2Style: { 275 + marginBottom: spacing[3], 276 + }, 277 + h3Style: { 278 + marginBottom: spacing[3], 279 + }, 280 + h4Style: { 281 + marginBottom: spacing[2], 282 + }, 283 + h5Style: { 284 + marginBottom: spacing[2], 285 + }, 286 + h6Style: { 287 + marginBottom: spacing[2], 288 + }, 289 + subtitle1Style: { 290 + marginBottom: spacing[2], 291 + }, 292 + subtitle2Style: { 293 + marginBottom: spacing[1], 294 + }, 295 + body1Style: { 296 + marginBottom: spacing[3], 297 + }, 298 + body2Style: { 299 + marginBottom: spacing[2], 300 + }, 301 + captionStyle: { 302 + marginBottom: spacing[1], 303 + }, 304 + overlineStyle: { 305 + marginBottom: spacing[1], 306 + textTransform: "uppercase", 307 + letterSpacing: 1, 308 + }, 309 + labelStyle: { 310 + marginBottom: spacing[1], 311 + }, 312 + buttonStyle: { 313 + textAlign: "center", 314 + }, 315 + codeStyle: { 316 + fontFamily: "monospace", 317 + backgroundColor: colors["muted"], 318 + paddingHorizontal: spacing[1], 319 + paddingVertical: 2, 320 + borderRadius: radius.sm, 321 + fontSize: 14, 322 + }, 323 + }); 324 + } 325 + 326 + // Export text variants for external use 327 + export { textVariants }; 328 + 329 + // Re-export primitive components for advanced usage 330 + export { TextPrimitive };
+203
js/components/src/components/ui/toast.tsx
··· 1 + import { Portal } from "@rn-primitives/portal"; 2 + import { useEffect, useState } from "react"; 3 + import { 4 + Animated, 5 + Platform, 6 + Pressable, 7 + StyleSheet, 8 + Text, 9 + useWindowDimensions, 10 + View, 11 + ViewStyle, 12 + } from "react-native"; 13 + import { useSafeAreaInsets } from "react-native-safe-area-context"; 14 + import { useTheme } from "../../lib/theme/theme"; 15 + 16 + type ToastProps = { 17 + open: boolean; 18 + onOpenChange: (open: boolean) => void; 19 + title: string; 20 + description?: string; 21 + actionLabel?: string; 22 + onAction?: () => void; 23 + duration?: number; // seconds 24 + }; 25 + 26 + export function Toast({ 27 + open, 28 + onOpenChange, 29 + title, 30 + description, 31 + actionLabel = "Action", 32 + onAction, 33 + duration = 3, 34 + }: ToastProps) { 35 + const [seconds, setSeconds] = useState(duration); 36 + const insets = useSafeAreaInsets(); 37 + const { theme } = useTheme(); 38 + const [fadeAnim] = useState(new Animated.Value(0)); 39 + const { width } = useWindowDimensions(); 40 + const isWeb = Platform.OS === "web"; 41 + const isDesktop = isWeb && width >= 768; 42 + 43 + const containerPosition: ViewStyle = isDesktop 44 + ? { 45 + top: undefined, 46 + bottom: theme.spacing[4], 47 + right: theme.spacing[4], // <-- use spacing, not 1 48 + alignItems: "flex-end", 49 + minWidth: 400, 50 + width: 400, 51 + // Do NOT set left at all 52 + } 53 + : { 54 + bottom: insets.bottom + theme.spacing[1], 55 + left: 0, 56 + right: 0, 57 + alignItems: "center", 58 + width: "100%", 59 + maxWidth: undefined, 60 + }; 61 + 62 + useEffect(() => { 63 + let interval: ReturnType<typeof setInterval> | null = null; 64 + 65 + if (open) { 66 + setSeconds(duration); 67 + Animated.timing(fadeAnim, { 68 + toValue: 1, 69 + duration: 200, 70 + useNativeDriver: true, 71 + }).start(); 72 + 73 + interval = setInterval(() => { 74 + setSeconds((prev) => { 75 + if (prev <= 1) { 76 + onOpenChange(false); 77 + if (interval) clearInterval(interval); 78 + return duration; 79 + } 80 + return prev - 1; 81 + }); 82 + }, 1000); 83 + } else { 84 + if (interval) clearInterval(interval); 85 + Animated.timing(fadeAnim, { 86 + toValue: 0, 87 + duration: 150, 88 + useNativeDriver: true, 89 + }).start(); 90 + setSeconds(duration); 91 + } 92 + 93 + return () => { 94 + if (interval) clearInterval(interval); 95 + }; 96 + // eslint-disable-next-line 97 + }, [open, duration]); 98 + 99 + if (!open) return null; 100 + 101 + return ( 102 + <Portal name="toast"> 103 + <Animated.View 104 + style={[styles.container, containerPosition, { opacity: fadeAnim }]} 105 + pointerEvents="box-none" 106 + > 107 + <View 108 + style={[ 109 + styles.toast, 110 + { 111 + backgroundColor: theme.colors.secondary, 112 + borderColor: theme.colors.border, 113 + borderRadius: theme.borderRadius.xl, 114 + flexDirection: "column", 115 + justifyContent: "space-between", 116 + alignItems: "center", 117 + padding: theme.spacing[4], 118 + width: isDesktop ? "100%" : "95%", 119 + }, 120 + ]} 121 + > 122 + <View style={{ gap: theme.spacing[1], width: "100%" }}> 123 + <Text 124 + style={[ 125 + { 126 + color: theme.colors.foreground, 127 + fontSize: 16, 128 + fontWeight: "500", 129 + }, 130 + ]} 131 + > 132 + {title} 133 + </Text> 134 + {description ? ( 135 + <Text style={[{ color: theme.colors.foreground, fontSize: 14 }]}> 136 + {description} 137 + </Text> 138 + ) : null} 139 + </View> 140 + <View 141 + style={{ 142 + gap: theme.spacing[1], 143 + flexDirection: "row", 144 + justifyContent: "flex-end", 145 + width: "100%", 146 + }} 147 + > 148 + {onAction && ( 149 + <Pressable 150 + style={[ 151 + styles.button, 152 + { 153 + borderColor: theme.colors.primary, 154 + paddingHorizontal: theme.spacing[4], 155 + paddingVertical: theme.spacing[2], 156 + }, 157 + ]} 158 + onPress={onAction} 159 + > 160 + <Text style={{ color: theme.colors.foreground }}> 161 + {actionLabel} 162 + </Text> 163 + </Pressable> 164 + )} 165 + <Pressable 166 + style={[ 167 + styles.button, 168 + { 169 + borderColor: theme.colors.primary, 170 + paddingHorizontal: theme.spacing[4], 171 + paddingVertical: theme.spacing[2], 172 + }, 173 + ]} 174 + onPress={() => onOpenChange(false)} 175 + > 176 + <Text style={{ color: theme.colors.foreground }}>Close</Text> 177 + </Pressable> 178 + </View> 179 + </View> 180 + </Animated.View> 181 + </Portal> 182 + ); 183 + } 184 + 185 + const styles = StyleSheet.create({ 186 + container: { 187 + position: "absolute", 188 + zIndex: 1000, 189 + paddingHorizontal: 16, 190 + }, 191 + toast: { 192 + opacity: 0.95, 193 + borderWidth: 1, 194 + gap: 8, 195 + }, 196 + button: { 197 + borderWidth: 1, 198 + borderRadius: 8, 199 + alignItems: "center", 200 + justifyContent: "center", 201 + backgroundColor: "transparent", 202 + }, 203 + });
+344
js/components/src/components/ui/view.tsx
··· 1 + import { cva, type VariantProps } from "class-variance-authority"; 2 + import { forwardRef } from "react"; 3 + import { 4 + View as RNView, 5 + ViewProps as RNViewProps, 6 + ViewStyle, 7 + } from "react-native"; 8 + import { borderRadius as radius, spacing } from "../../lib/theme/atoms"; 9 + import { useTheme } from "../../lib/theme/theme"; 10 + 11 + // View variants using class-variance-authority pattern 12 + const viewVariants = cva("", { 13 + variants: { 14 + variant: { 15 + default: "default", 16 + card: "card", 17 + overlay: "overlay", 18 + surface: "surface", 19 + container: "container", 20 + }, 21 + padding: { 22 + none: "none", 23 + xs: "xs", 24 + sm: "sm", 25 + md: "md", 26 + lg: "lg", 27 + xl: "xl", 28 + }, 29 + margin: { 30 + none: "none", 31 + xs: "xs", 32 + sm: "sm", 33 + md: "md", 34 + lg: "lg", 35 + xl: "xl", 36 + }, 37 + direction: { 38 + row: "row", 39 + column: "column", 40 + "row-reverse": "row-reverse", 41 + "column-reverse": "column-reverse", 42 + }, 43 + align: { 44 + start: "start", 45 + center: "center", 46 + end: "end", 47 + stretch: "stretch", 48 + baseline: "baseline", 49 + }, 50 + justify: { 51 + start: "start", 52 + center: "center", 53 + end: "end", 54 + between: "between", 55 + around: "around", 56 + evenly: "evenly", 57 + }, 58 + flex: { 59 + none: "none", 60 + auto: "auto", 61 + initial: "initial", 62 + }, 63 + }, 64 + defaultVariants: { 65 + variant: "default", 66 + padding: "none", 67 + margin: "none", 68 + direction: "column", 69 + align: "stretch", 70 + justify: "start", 71 + flex: "none", 72 + }, 73 + }); 74 + 75 + export interface ViewProps 76 + extends Omit<RNViewProps, "style">, 77 + Omit<VariantProps<typeof viewVariants>, "flex"> { 78 + // Style props 79 + style?: ViewStyle | ViewStyle[]; 80 + 81 + // Convenience props 82 + fullWidth?: boolean; 83 + fullHeight?: boolean; 84 + centered?: boolean; 85 + 86 + // Background 87 + backgroundColor?: string; 88 + 89 + // Border 90 + borderColor?: string; 91 + borderWidth?: number; 92 + borderRadius?: number; 93 + 94 + // Shadow (web only) 95 + shadow?: boolean; 96 + 97 + // Custom flex values 98 + flex?: number | "none" | "auto" | "initial"; 99 + } 100 + 101 + export const View = forwardRef<RNView, ViewProps>( 102 + ( 103 + { 104 + variant = "default", 105 + padding = "none", 106 + margin = "none", 107 + direction = "column", 108 + align = "stretch", 109 + justify = "start", 110 + flex = "none", 111 + fullWidth = false, 112 + fullHeight = false, 113 + centered = false, 114 + backgroundColor, 115 + borderColor, 116 + borderWidth, 117 + borderRadius, 118 + shadow = false, 119 + style, 120 + ...props 121 + }, 122 + ref, 123 + ) => { 124 + const { theme } = useTheme(); 125 + 126 + // Map variant to styles 127 + const variantStyles: ViewStyle = (() => { 128 + switch (variant) { 129 + case "card": 130 + return { 131 + backgroundColor: theme.colors.card, 132 + borderRadius: radius.lg, 133 + shadowColor: "#000", 134 + shadowOffset: { width: 0, height: 2 }, 135 + shadowOpacity: 0.1, 136 + shadowRadius: 4, 137 + elevation: 3, 138 + }; 139 + case "overlay": 140 + return { 141 + backgroundColor: "rgba(0, 0, 0, 0.5)", 142 + }; 143 + case "surface": 144 + return { 145 + backgroundColor: theme.colors.background, 146 + }; 147 + case "container": 148 + return { 149 + backgroundColor: theme.colors.background, 150 + padding: spacing[4], 151 + }; 152 + default: 153 + return {}; 154 + } 155 + })(); 156 + 157 + // Map padding to numeric values 158 + const paddingValue = (() => { 159 + switch (padding) { 160 + case "xs": 161 + return spacing[1]; 162 + case "sm": 163 + return spacing[2]; 164 + case "md": 165 + return spacing[3]; 166 + case "lg": 167 + return spacing[4]; 168 + case "xl": 169 + return spacing[5]; 170 + default: 171 + return undefined; 172 + } 173 + })(); 174 + 175 + // Map margin to numeric values 176 + const marginValue = (() => { 177 + switch (margin) { 178 + case "xs": 179 + return spacing[1]; 180 + case "sm": 181 + return spacing[2]; 182 + case "md": 183 + return spacing[3]; 184 + case "lg": 185 + return spacing[4]; 186 + case "xl": 187 + return spacing[5]; 188 + default: 189 + return undefined; 190 + } 191 + })(); 192 + 193 + // Map flex direction 194 + const flexDirection = (() => { 195 + switch (direction) { 196 + case "row": 197 + return "row"; 198 + case "column": 199 + return "column"; 200 + case "row-reverse": 201 + return "row-reverse"; 202 + case "column-reverse": 203 + return "column-reverse"; 204 + default: 205 + return "column"; 206 + } 207 + })() as ViewStyle["flexDirection"]; 208 + 209 + // Map align items 210 + const alignItems = (() => { 211 + switch (align) { 212 + case "start": 213 + return "flex-start"; 214 + case "center": 215 + return "center"; 216 + case "end": 217 + return "flex-end"; 218 + case "stretch": 219 + return "stretch"; 220 + case "baseline": 221 + return "baseline"; 222 + default: 223 + return "stretch"; 224 + } 225 + })() as ViewStyle["alignItems"]; 226 + 227 + // Map justify content 228 + const justifyContent = (() => { 229 + switch (justify) { 230 + case "start": 231 + return "flex-start"; 232 + case "center": 233 + return "center"; 234 + case "end": 235 + return "flex-end"; 236 + case "between": 237 + return "space-between"; 238 + case "around": 239 + return "space-around"; 240 + case "evenly": 241 + return "space-evenly"; 242 + default: 243 + return "flex-start"; 244 + } 245 + })() as ViewStyle["justifyContent"]; 246 + 247 + // Map flex value 248 + const flexValue = (() => { 249 + if (typeof flex === "number") { 250 + return flex; 251 + } 252 + switch (flex) { 253 + case "auto": 254 + return undefined; // auto is default 255 + case "initial": 256 + return 0; 257 + case "none": 258 + default: 259 + return undefined; 260 + } 261 + })(); 262 + 263 + const computedStyle: ViewStyle = { 264 + ...variantStyles, 265 + ...(paddingValue !== undefined && { padding: paddingValue }), 266 + ...(marginValue !== undefined && { margin: marginValue }), 267 + flexDirection, 268 + alignItems, 269 + justifyContent, 270 + ...(flexValue !== undefined && { flex: flexValue }), 271 + ...(fullWidth && { width: "100%" }), 272 + ...(fullHeight && { height: "100%" }), 273 + ...(centered && { 274 + alignItems: "center", 275 + justifyContent: "center", 276 + }), 277 + ...(backgroundColor && { backgroundColor }), 278 + ...(borderColor && { borderColor }), 279 + ...(borderWidth !== undefined && { borderWidth }), 280 + ...(borderRadius !== undefined && { borderRadius }), 281 + ...(shadow && { 282 + shadowColor: "#000", 283 + shadowOffset: { width: 0, height: 2 }, 284 + shadowOpacity: 0.1, 285 + shadowRadius: 4, 286 + elevation: 3, 287 + }), 288 + }; 289 + 290 + const finalStyle = Array.isArray(style) 291 + ? [computedStyle, ...style] 292 + : [computedStyle, style]; 293 + 294 + return <RNView ref={ref} style={finalStyle} {...props} />; 295 + }, 296 + ); 297 + 298 + View.displayName = "View"; 299 + 300 + // Convenience components 301 + export const Card = forwardRef<RNView, Omit<ViewProps, "variant">>( 302 + (props, ref) => <View ref={ref} variant="card" {...props} />, 303 + ); 304 + 305 + Card.displayName = "Card"; 306 + 307 + export const Container = forwardRef<RNView, Omit<ViewProps, "variant">>( 308 + (props, ref) => <View ref={ref} variant="container" {...props} />, 309 + ); 310 + 311 + Container.displayName = "Container"; 312 + 313 + export const Surface = forwardRef<RNView, Omit<ViewProps, "variant">>( 314 + (props, ref) => <View ref={ref} variant="surface" {...props} />, 315 + ); 316 + 317 + Surface.displayName = "Surface"; 318 + 319 + export const Overlay = forwardRef<RNView, Omit<ViewProps, "variant">>( 320 + (props, ref) => <View ref={ref} variant="overlay" {...props} />, 321 + ); 322 + 323 + Overlay.displayName = "Overlay"; 324 + 325 + export const Row = forwardRef<RNView, Omit<ViewProps, "direction">>( 326 + (props, ref) => <View ref={ref} direction="row" {...props} />, 327 + ); 328 + 329 + Row.displayName = "Row"; 330 + 331 + export const Column = forwardRef<RNView, Omit<ViewProps, "direction">>( 332 + (props, ref) => <View ref={ref} direction="column" {...props} />, 333 + ); 334 + 335 + Column.displayName = "Column"; 336 + 337 + export const Center = forwardRef<RNView, ViewProps>((props, ref) => ( 338 + <View ref={ref} centered {...props} /> 339 + )); 340 + 341 + Center.displayName = "Center"; 342 + 343 + // Export view variants for external use 344 + export { viewVariants };
+21
js/components/src/index.tsx
··· 4 4 export * from "./player-store"; 5 5 export * from "./streamplace-provider"; 6 6 export * from "./streamplace-store"; 7 + 8 + // export PlayerProvider and related hooks/types for direct package imports 9 + export { 10 + PlayerProvider, 11 + withPlayerProvider, 12 + } from "./player-store/player-provider"; 13 + export { usePlayerContext } from "./player-store/player-store"; 14 + 15 + // export Player and PlayerProps for direct package imports 16 + export { Player, PlayerUI } from "./components/mobile-player/player"; 17 + export { PlayerProps } from "./components/mobile-player/props"; 18 + 19 + // export theme 20 + export * as ui from "./components/ui"; 21 + 22 + // export all UI components at the root for direct imports 23 + export * from "./components/ui"; 24 + 25 + // export atoms and theme for direct imports 26 + export * as theme from "./lib/theme"; 27 + export * as atoms from "./lib/theme/atoms";
+760
js/components/src/lib/theme/atoms.ts
··· 1 + /** 2 + * Theme atoms - Enhanced exports with pairify function for array-style syntax 3 + * These provide direct access to static design tokens and support composition 4 + */ 5 + 6 + import { Platform } from "react-native"; 7 + import { 8 + animations, 9 + borderRadius, 10 + colors as rawColors, 11 + shadows, 12 + spacing, 13 + touchTargets, 14 + typography, 15 + } from "./tokens"; 16 + 17 + // Type for style objects that can be spread 18 + type StyleValue = Record<string, any>; 19 + 20 + /** 21 + * Pairify function - converts nested objects into key-value pairs that return style objects 22 + * This allows for array-style syntax like style={[a.borders.green[300], a.shadows.xl]} 23 + */ 24 + function pairify<T extends Record<string, any>>( 25 + obj: T, 26 + styleKeyPrefix: string, 27 + ): Record<keyof T, StyleValue> { 28 + const result: Record<string, StyleValue> = {}; 29 + 30 + for (const [key, value] of Object.entries(obj)) { 31 + if (typeof value === "object" && value !== null && !Array.isArray(value)) { 32 + // For nested objects (like color scales), create another level 33 + result[key] = {}; 34 + for (const [nestedKey, nestedValue] of Object.entries(value)) { 35 + result[key][nestedKey] = { [styleKeyPrefix]: nestedValue }; 36 + } 37 + } else { 38 + // For simple values, create the style object directly 39 + result[key] = { [styleKeyPrefix]: value }; 40 + } 41 + } 42 + 43 + return result as Record<keyof T, StyleValue>; 44 + } 45 + 46 + /** 47 + * Create pairified style atoms for easy composition 48 + */ 49 + 50 + // Re-export static design tokens that don't change with theme 51 + export { animations, borderRadius, shadows, spacing, touchTargets }; 52 + 53 + // Export raw color tokens for advanced use cases 54 + export const colors = rawColors; 55 + 56 + // Platform-aware typography helper 57 + export const getPlatformTypography = () => { 58 + if (Platform.OS === "ios") { 59 + return typography.ios; 60 + } else if (Platform.OS === "android") { 61 + return typography.android; 62 + } 63 + return typography.universal; 64 + }; 65 + 66 + // Export all typography scales 67 + export const typographyAtoms = { 68 + platform: getPlatformTypography(), 69 + universal: typography.universal, 70 + ios: typography.ios, 71 + android: typography.android, 72 + }; 73 + 74 + // Static icon sizes (colors are handled by theme) 75 + export const iconSizes = { 76 + sm: 16, 77 + md: 20, 78 + lg: 24, 79 + xl: 32, 80 + }; 81 + 82 + // Common layout utilities 83 + export const layout = { 84 + flex: { 85 + center: { 86 + justifyContent: "center" as const, 87 + alignItems: "center" as const, 88 + }, 89 + alignCenter: { 90 + alignItems: "center" as const, 91 + }, 92 + justifyCenter: { 93 + justifyContent: "center" as const, 94 + }, 95 + row: { 96 + flexDirection: "row" as const, 97 + }, 98 + column: { 99 + flexDirection: "column" as const, 100 + }, 101 + spaceBetween: { 102 + justifyContent: "space-between" as const, 103 + }, 104 + spaceAround: { 105 + justifyContent: "space-around" as const, 106 + }, 107 + spaceEvenly: { 108 + justifyContent: "space-evenly" as const, 109 + }, 110 + }, 111 + position: { 112 + absolute: { 113 + position: "absolute" as const, 114 + }, 115 + relative: { 116 + position: "relative" as const, 117 + }, 118 + }, 119 + }; 120 + 121 + // Enhanced border utilities with pairified colors and widths 122 + export const borders = { 123 + width: pairify( 124 + { 125 + thin: 1, 126 + medium: 2, 127 + thick: 4, 128 + }, 129 + "borderWidth", 130 + ), 131 + 132 + style: { 133 + solid: { borderStyle: "solid" as const }, 134 + dashed: { borderStyle: "dashed" as const }, 135 + dotted: { borderStyle: "dotted" as const }, 136 + }, 137 + 138 + // Pairified color borders 139 + color: pairify(rawColors, "borderColor"), 140 + 141 + // Top border utilities 142 + top: { 143 + width: pairify( 144 + { 145 + thin: 1, 146 + medium: 2, 147 + thick: 4, 148 + }, 149 + "borderTopWidth", 150 + ), 151 + color: pairify(rawColors, "borderTopColor"), 152 + }, 153 + 154 + // Bottom border utilities 155 + bottom: { 156 + width: pairify( 157 + { 158 + thin: 1, 159 + medium: 2, 160 + thick: 4, 161 + }, 162 + "borderBottomWidth", 163 + ), 164 + color: pairify(rawColors, "borderBottomColor"), 165 + }, 166 + 167 + // Left border utilities 168 + left: { 169 + width: pairify( 170 + { 171 + thin: 1, 172 + medium: 2, 173 + thick: 4, 174 + }, 175 + "borderLeftWidth", 176 + ), 177 + color: pairify(rawColors, "borderLeftColor"), 178 + }, 179 + 180 + // Right border utilities 181 + right: { 182 + width: pairify( 183 + { 184 + thin: 1, 185 + medium: 2, 186 + thick: 4, 187 + }, 188 + "borderRightWidth", 189 + ), 190 + color: pairify(rawColors, "borderRightColor"), 191 + }, 192 + }; 193 + 194 + // Pairified spacing utilities 195 + export const spacingAtoms = { 196 + margin: pairify(spacing, "margin"), 197 + marginTop: pairify(spacing, "marginTop"), 198 + marginRight: pairify(spacing, "marginRight"), 199 + marginBottom: pairify(spacing, "marginBottom"), 200 + marginLeft: pairify(spacing, "marginLeft"), 201 + marginHorizontal: pairify(spacing, "marginHorizontal"), 202 + marginVertical: pairify(spacing, "marginVertical"), 203 + 204 + padding: pairify(spacing, "padding"), 205 + paddingTop: pairify(spacing, "paddingTop"), 206 + paddingRight: pairify(spacing, "paddingRight"), 207 + paddingBottom: pairify(spacing, "paddingBottom"), 208 + paddingLeft: pairify(spacing, "paddingLeft"), 209 + paddingHorizontal: pairify(spacing, "paddingHorizontal"), 210 + paddingVertical: pairify(spacing, "paddingVertical"), 211 + }; 212 + 213 + // Pairified border radius utilities 214 + export const radiusAtoms = { 215 + all: pairify(borderRadius, "borderRadius"), 216 + top: pairify(borderRadius, "borderTopLeftRadius"), 217 + topRight: pairify(borderRadius, "borderTopRightRadius"), 218 + bottom: pairify(borderRadius, "borderBottomLeftRadius"), 219 + bottomRight: pairify(borderRadius, "borderBottomRightRadius"), 220 + left: pairify(borderRadius, "borderTopLeftRadius"), 221 + right: pairify(borderRadius, "borderTopRightRadius"), 222 + }; 223 + 224 + // Background color utilities 225 + export const backgrounds = pairify(rawColors, "backgroundColor"); 226 + 227 + // Text color utilities 228 + export const textColors = pairify(rawColors, "color"); 229 + 230 + // Percentage-based sizes 231 + const percentageSizes = { 232 + "10": "10%", 233 + "20": "20%", 234 + "25": "25%", 235 + "30": "30%", 236 + "33": "33.333333%", 237 + "40": "40%", 238 + "50": "50%", 239 + "60": "60%", 240 + "66": "66.666667%", 241 + "70": "70%", 242 + "75": "75%", 243 + "80": "80%", 244 + "90": "90%", 245 + "100": "100%", 246 + } as const; 247 + 248 + // Size utilities (width and height) 249 + export const sizes = { 250 + width: { 251 + ...pairify(spacing, "width"), 252 + percent: pairify(percentageSizes, "width"), 253 + }, 254 + height: { 255 + ...pairify(spacing, "height"), 256 + percent: pairify(percentageSizes, "height"), 257 + }, 258 + minWidth: { 259 + ...pairify(spacing, "minWidth"), 260 + percent: pairify(percentageSizes, "minWidth"), 261 + }, 262 + minHeight: { 263 + ...pairify(spacing, "minHeight"), 264 + percent: pairify(percentageSizes, "minHeight"), 265 + }, 266 + maxWidth: { 267 + ...pairify(spacing, "maxWidth"), 268 + percent: pairify(percentageSizes, "maxWidth"), 269 + }, 270 + maxHeight: { 271 + ...pairify(spacing, "maxHeight"), 272 + percent: pairify(percentageSizes, "maxHeight"), 273 + }, 274 + }; 275 + 276 + // Flex utilities 277 + export const flex = { 278 + values: pairify( 279 + { 280 + 0: 0, 281 + 1: 1, 282 + 2: 2, 283 + 3: 3, 284 + 4: 4, 285 + 5: 5, 286 + }, 287 + "flex", 288 + ), 289 + 290 + grow: pairify( 291 + { 292 + 0: 0, 293 + 1: 1, 294 + }, 295 + "flexGrow", 296 + ), 297 + 298 + shrink: pairify( 299 + { 300 + 0: 0, 301 + 1: 1, 302 + }, 303 + "flexShrink", 304 + ), 305 + 306 + basis: { 307 + ...pairify(spacing, "flexBasis"), 308 + ...pairify(percentageSizes, "flexBasis"), 309 + auto: { flexBasis: "auto" }, 310 + }, 311 + }; 312 + 313 + // Opacity utilities 314 + export const opacity = pairify( 315 + { 316 + 0: 0, 317 + 5: 0.05, 318 + 10: 0.1, 319 + 20: 0.2, 320 + 25: 0.25, 321 + 30: 0.3, 322 + 40: 0.4, 323 + 50: 0.5, 324 + 60: 0.6, 325 + 70: 0.7, 326 + 75: 0.75, 327 + 80: 0.8, 328 + 90: 0.9, 329 + 95: 0.95, 330 + 100: 1, 331 + }, 332 + "opacity", 333 + ); 334 + 335 + // Z-index utilities 336 + export const zIndex = pairify( 337 + { 338 + 0: 0, 339 + 10: 10, 340 + 20: 20, 341 + 30: 30, 342 + 40: 40, 343 + 50: 50, 344 + auto: "auto", 345 + }, 346 + "zIndex", 347 + ); 348 + 349 + // Overflow utilities 350 + export const overflow = { 351 + visible: { overflow: "visible" as const }, 352 + hidden: { overflow: "hidden" as const }, 353 + scroll: { overflow: "scroll" as const }, 354 + }; 355 + 356 + // Text alignment utilities 357 + export const textAlign = { 358 + left: { textAlign: "left" as const }, 359 + center: { textAlign: "center" as const }, 360 + right: { textAlign: "right" as const }, 361 + justify: { textAlign: "justify" as const }, 362 + auto: { textAlign: "auto" as const }, 363 + }; 364 + 365 + // Font weight utilities 366 + export const fontWeight = pairify( 367 + { 368 + thin: "100", 369 + extralight: "200", 370 + light: "300", 371 + normal: "400", 372 + medium: "500", 373 + semibold: "600", 374 + bold: "700", 375 + extrabold: "800", 376 + black: "900", 377 + }, 378 + "fontWeight", 379 + ); 380 + 381 + // Font size utilities (separate from typography for quick access) 382 + export const fontSize = pairify( 383 + { 384 + xs: 12, 385 + sm: 14, 386 + base: 16, 387 + lg: 18, 388 + xl: 20, 389 + "2xl": 24, 390 + "3xl": 30, 391 + "4xl": 36, 392 + "5xl": 48, 393 + "6xl": 60, 394 + "7xl": 72, 395 + "8xl": 96, 396 + "9xl": 128, 397 + }, 398 + "fontSize", 399 + ); 400 + 401 + // Line height utilities 402 + export const lineHeight = pairify( 403 + { 404 + none: 1, 405 + tight: 1.25, 406 + snug: 1.375, 407 + normal: 1.5, 408 + relaxed: 1.625, 409 + loose: 2, 410 + 3: 12, 411 + 4: 16, 412 + 5: 20, 413 + 6: 24, 414 + 7: 28, 415 + 8: 32, 416 + 9: 36, 417 + 10: 40, 418 + }, 419 + "lineHeight", 420 + ); 421 + 422 + // Letter spacing utilities 423 + export const letterSpacing = pairify( 424 + { 425 + tighter: -0.5, 426 + tight: -0.25, 427 + normal: 0, 428 + wide: 0.25, 429 + wider: 0.5, 430 + widest: 1, 431 + }, 432 + "letterSpacing", 433 + ); 434 + 435 + // Text transform utilities 436 + export const textTransform = { 437 + uppercase: { textTransform: "uppercase" as const }, 438 + lowercase: { textTransform: "lowercase" as const }, 439 + capitalize: { textTransform: "capitalize" as const }, 440 + none: { textTransform: "none" as const }, 441 + }; 442 + 443 + // Text decoration utilities 444 + export const textDecoration = { 445 + none: { textDecorationLine: "none" as const }, 446 + underline: { textDecorationLine: "underline" as const }, 447 + lineThrough: { textDecorationLine: "line-through" as const }, 448 + underlineLineThrough: { 449 + textDecorationLine: "underline line-through" as const, 450 + }, 451 + }; 452 + 453 + // Text align vertical utilities (React Native specific) 454 + export const textAlignVertical = { 455 + auto: { textAlignVertical: "auto" as const }, 456 + top: { textAlignVertical: "top" as const }, 457 + bottom: { textAlignVertical: "bottom" as const }, 458 + center: { textAlignVertical: "center" as const }, 459 + }; 460 + 461 + // Transform utilities 462 + export const transforms = { 463 + rotate: pairify( 464 + { 465 + 0: 0, 466 + 1: 1, 467 + 2: 2, 468 + 3: 3, 469 + 6: 6, 470 + 12: 12, 471 + 45: 45, 472 + 90: 90, 473 + 180: 180, 474 + 270: 270, 475 + }, 476 + "rotate", 477 + ), 478 + 479 + scale: pairify( 480 + { 481 + 0: 0, 482 + 50: 0.5, 483 + 75: 0.75, 484 + 90: 0.9, 485 + 95: 0.95, 486 + 100: 1, 487 + 105: 1.05, 488 + 110: 1.1, 489 + 125: 1.25, 490 + 150: 1.5, 491 + 200: 2, 492 + }, 493 + "scale", 494 + ), 495 + 496 + scaleX: pairify( 497 + { 498 + 0: 0, 499 + 50: 0.5, 500 + 75: 0.75, 501 + 90: 0.9, 502 + 95: 0.95, 503 + 100: 1, 504 + 105: 1.05, 505 + 110: 1.1, 506 + 125: 1.25, 507 + 150: 1.5, 508 + 200: 2, 509 + }, 510 + "scaleX", 511 + ), 512 + 513 + scaleY: pairify( 514 + { 515 + 0: 0, 516 + 50: 0.5, 517 + 75: 0.75, 518 + 90: 0.9, 519 + 95: 0.95, 520 + 100: 1, 521 + 105: 1.05, 522 + 110: 1.1, 523 + 125: 1.25, 524 + 150: 1.5, 525 + 200: 2, 526 + }, 527 + "scaleY", 528 + ), 529 + 530 + translateX: pairify(spacing, "translateX"), 531 + translateY: pairify(spacing, "translateY"), 532 + }; 533 + 534 + // Absolute positioning utilities 535 + export const position = { 536 + top: pairify(spacing, "top"), 537 + right: pairify(spacing, "right"), 538 + bottom: pairify(spacing, "bottom"), 539 + left: pairify(spacing, "left"), 540 + 541 + // Common position combinations 542 + topLeft: (top: number, left: number) => ({ 543 + position: "absolute" as const, 544 + top, 545 + left, 546 + }), 547 + topRight: (top: number, right: number) => ({ 548 + position: "absolute" as const, 549 + top, 550 + right, 551 + }), 552 + bottomLeft: (bottom: number, left: number) => ({ 553 + position: "absolute" as const, 554 + bottom, 555 + left, 556 + }), 557 + bottomRight: (bottom: number, right: number) => ({ 558 + position: "absolute" as const, 559 + bottom, 560 + right, 561 + }), 562 + 563 + // Percentage-based positioning 564 + percent: { 565 + top: pairify( 566 + { 567 + 0: "0%", 568 + 25: "25%", 569 + 50: "50%", 570 + 75: "75%", 571 + 100: "100%", 572 + }, 573 + "top", 574 + ), 575 + right: pairify( 576 + { 577 + 0: "0%", 578 + 25: "25%", 579 + 50: "50%", 580 + 75: "75%", 581 + 100: "100%", 582 + }, 583 + "right", 584 + ), 585 + bottom: pairify( 586 + { 587 + 0: "0%", 588 + 25: "25%", 589 + 50: "50%", 590 + 75: "75%", 591 + 100: "100%", 592 + }, 593 + "bottom", 594 + ), 595 + left: pairify( 596 + { 597 + 0: "0%", 598 + 25: "25%", 599 + 50: "50%", 600 + 75: "75%", 601 + 100: "100%", 602 + }, 603 + "left", 604 + ), 605 + }, 606 + }; 607 + 608 + // Aspect ratio utilities (React Native 0.71+) 609 + export const aspectRatio = pairify( 610 + { 611 + square: 1, 612 + video: 16 / 9, 613 + photo: 4 / 3, 614 + portrait: 3 / 4, 615 + wide: 21 / 9, 616 + ultrawide: 32 / 9, 617 + "1/1": 1, 618 + "3/2": 3 / 2, 619 + "4/3": 4 / 3, 620 + "16/9": 16 / 9, 621 + "21/9": 21 / 9, 622 + }, 623 + "aspectRatio", 624 + ); 625 + 626 + // Gap utilities (React Native 0.71+) 627 + export const gap = { 628 + row: pairify(spacing, "rowGap"), 629 + column: pairify(spacing, "columnGap"), 630 + all: pairify(spacing, "gap"), 631 + }; 632 + 633 + // Common layout patterns 634 + export const layouts = { 635 + // Full screen 636 + fullScreen: { 637 + position: "absolute" as const, 638 + top: 0, 639 + left: 0, 640 + right: 0, 641 + bottom: 0, 642 + }, 643 + 644 + // Centered content 645 + centered: { 646 + flex: 1, 647 + justifyContent: "center" as const, 648 + alignItems: "center" as const, 649 + }, 650 + 651 + // Centered modal/overlay 652 + overlay: { 653 + position: "absolute" as const, 654 + top: 0, 655 + left: 0, 656 + right: 0, 657 + bottom: 0, 658 + justifyContent: "center" as const, 659 + alignItems: "center" as const, 660 + backgroundColor: "rgba(0, 0, 0, 0.5)", 661 + }, 662 + 663 + // Safe area friendly 664 + safeContainer: { 665 + flex: 1, 666 + paddingTop: Platform.OS === "ios" ? 44 : 0, // Status bar height 667 + }, 668 + 669 + // Row with space between 670 + spaceBetweenRow: { 671 + flexDirection: "row" as const, 672 + justifyContent: "space-between" as const, 673 + alignItems: "center" as const, 674 + }, 675 + 676 + // Sticky header 677 + stickyHeader: { 678 + position: "absolute" as const, 679 + top: 0, 680 + left: 0, 681 + right: 0, 682 + zIndex: 10, 683 + }, 684 + 685 + // Bottom sheet style 686 + bottomSheet: { 687 + position: "absolute" as const, 688 + bottom: 0, 689 + left: 0, 690 + right: 0, 691 + borderTopLeftRadius: 16, 692 + borderTopRightRadius: 16, 693 + }, 694 + }; 695 + 696 + // Export everything as a combined atoms object for convenience 697 + export const atoms = { 698 + colors: rawColors, 699 + spacing, 700 + borderRadius, 701 + radius: radiusAtoms, 702 + typography: typographyAtoms, 703 + shadows, 704 + touchTargets, 705 + animations, 706 + iconSizes, 707 + layout, 708 + borders, 709 + backgrounds, 710 + textColors, 711 + spacingAtoms, 712 + sizes, 713 + flex, 714 + opacity, 715 + zIndex, 716 + overflow, 717 + textAlign, 718 + fontWeight, 719 + fontSize, 720 + lineHeight, 721 + letterSpacing, 722 + textTransform, 723 + textDecoration, 724 + textAlignVertical, 725 + transforms, 726 + position, 727 + aspectRatio, 728 + gap, 729 + layouts, 730 + }; 731 + 732 + // Convenient shorthand aliases 733 + export const a = atoms; 734 + export const bg = backgrounds; 735 + export const text = textColors; 736 + export const m = spacingAtoms.margin; 737 + export const mt = spacingAtoms.marginTop; 738 + export const mr = spacingAtoms.marginRight; 739 + export const mb = spacingAtoms.marginBottom; 740 + export const ml = spacingAtoms.marginLeft; 741 + export const mx = spacingAtoms.marginHorizontal; 742 + export const my = spacingAtoms.marginVertical; 743 + export const p = spacingAtoms.padding; 744 + export const pt = spacingAtoms.paddingTop; 745 + export const pr = spacingAtoms.paddingRight; 746 + export const pb = spacingAtoms.paddingBottom; 747 + export const pl = spacingAtoms.paddingLeft; 748 + export const px = spacingAtoms.paddingHorizontal; 749 + export const py = spacingAtoms.paddingVertical; 750 + export const w = sizes.width; 751 + export const h = sizes.height; 752 + export const r = radiusAtoms.all; 753 + export const top = position.top; 754 + export const right = position.right; 755 + export const bottom = position.bottom; 756 + export const left = position.left; 757 + export const rotate = transforms.rotate; 758 + export const scale = transforms.scale; 759 + export const translateX = transforms.translateX; 760 + export const translateY = transforms.translateY;
+258
js/components/src/lib/theme/atoms.types.ts
··· 1 + /** 2 + * Type definitions for enhanced atoms with pairify function 3 + * Provides comprehensive type safety for array-style syntax 4 + */ 5 + 6 + import type { TextStyle, ViewStyle } from "react-native"; 7 + import type { BorderRadius, Colors, Spacing } from "./tokens"; 8 + 9 + // Base style value type 10 + export type StyleValue = ViewStyle | TextStyle; 11 + 12 + // Pairified structure types 13 + export type PairifiedSpacing = { 14 + [K in keyof Spacing]: StyleValue; 15 + }; 16 + 17 + export type PairifiedColors = { 18 + [K in keyof Colors]: K extends string 19 + ? Colors[K] extends Record<string, any> 20 + ? { [N in keyof Colors[K]]: StyleValue } 21 + : StyleValue 22 + : never; 23 + }; 24 + 25 + export type PairifiedBorderRadius = { 26 + [K in keyof BorderRadius]: StyleValue; 27 + }; 28 + 29 + // Border utilities types 30 + export type BorderWidthAtoms = { 31 + thin: StyleValue; 32 + medium: StyleValue; 33 + thick: StyleValue; 34 + }; 35 + 36 + export type BorderStyleAtoms = { 37 + solid: StyleValue; 38 + dashed: StyleValue; 39 + dotted: StyleValue; 40 + }; 41 + 42 + export type BorderSideAtoms = { 43 + width: BorderWidthAtoms; 44 + color: PairifiedColors; 45 + }; 46 + 47 + export type BorderAtoms = { 48 + width: BorderWidthAtoms; 49 + style: BorderStyleAtoms; 50 + color: PairifiedColors; 51 + top: BorderSideAtoms; 52 + bottom: BorderSideAtoms; 53 + left: BorderSideAtoms; 54 + right: BorderSideAtoms; 55 + }; 56 + 57 + // Spacing atoms types 58 + export type SpacingAtoms = { 59 + margin: PairifiedSpacing; 60 + marginTop: PairifiedSpacing; 61 + marginRight: PairifiedSpacing; 62 + marginBottom: PairifiedSpacing; 63 + marginLeft: PairifiedSpacing; 64 + marginHorizontal: PairifiedSpacing; 65 + marginVertical: PairifiedSpacing; 66 + padding: PairifiedSpacing; 67 + paddingTop: PairifiedSpacing; 68 + paddingRight: PairifiedSpacing; 69 + paddingBottom: PairifiedSpacing; 70 + paddingLeft: PairifiedSpacing; 71 + paddingHorizontal: PairifiedSpacing; 72 + paddingVertical: PairifiedSpacing; 73 + }; 74 + 75 + // Border radius atoms types 76 + export type RadiusAtoms = { 77 + all: PairifiedBorderRadius; 78 + top: PairifiedBorderRadius; 79 + topRight: PairifiedBorderRadius; 80 + bottom: PairifiedBorderRadius; 81 + bottomRight: PairifiedBorderRadius; 82 + left: PairifiedBorderRadius; 83 + right: PairifiedBorderRadius; 84 + }; 85 + 86 + export type PercentSizeValues = { 87 + 10: StyleValue; 88 + 20: StyleValue; 89 + 25: StyleValue; 90 + 30: StyleValue; 91 + 33: StyleValue; 92 + 40: StyleValue; 93 + 50: StyleValue; 94 + 60: StyleValue; 95 + 66: StyleValue; 96 + 70: StyleValue; 97 + 75: StyleValue; 98 + 80: StyleValue; 99 + 90: StyleValue; 100 + 100: StyleValue; 101 + }; 102 + 103 + // Size utilities types 104 + export type SizeAtoms = { 105 + width: PairifiedSpacing & { percent: PercentSizeValues }; 106 + height: PairifiedSpacing & { percent: PercentSizeValues }; 107 + minWidth: PairifiedSpacing & { percent: PercentSizeValues }; 108 + minHeight: PairifiedSpacing & { percent: PercentSizeValues }; 109 + maxWidth: PairifiedSpacing & { percent: PercentSizeValues }; 110 + maxHeight: PairifiedSpacing & { percent: PercentSizeValues }; 111 + }; 112 + 113 + // Flex utilities types 114 + export type FlexValues = { 115 + 0: StyleValue; 116 + 1: StyleValue; 117 + 2: StyleValue; 118 + 3: StyleValue; 119 + 4: StyleValue; 120 + 5: StyleValue; 121 + }; 122 + 123 + export type FlexGrowShrinkValues = { 124 + 0: StyleValue; 125 + 1: StyleValue; 126 + }; 127 + 128 + export type FlexAtoms = { 129 + values: FlexValues; 130 + grow: FlexGrowShrinkValues; 131 + shrink: FlexGrowShrinkValues; 132 + basis: PairifiedSpacing & PercentSizeValues & { auto: StyleValue }; 133 + }; 134 + 135 + // Opacity utilities types 136 + export type OpacityAtoms = { 137 + 0: StyleValue; 138 + 5: StyleValue; 139 + 10: StyleValue; 140 + 20: StyleValue; 141 + 25: StyleValue; 142 + 30: StyleValue; 143 + 40: StyleValue; 144 + 50: StyleValue; 145 + 60: StyleValue; 146 + 70: StyleValue; 147 + 75: StyleValue; 148 + 80: StyleValue; 149 + 90: StyleValue; 150 + 95: StyleValue; 151 + 100: StyleValue; 152 + }; 153 + 154 + // Z-index utilities types 155 + export type ZIndexAtoms = { 156 + 0: StyleValue; 157 + 10: StyleValue; 158 + 20: StyleValue; 159 + 30: StyleValue; 160 + 40: StyleValue; 161 + 50: StyleValue; 162 + auto: StyleValue; 163 + }; 164 + 165 + // Overflow utilities types 166 + export type OverflowAtoms = { 167 + visible: StyleValue; 168 + hidden: StyleValue; 169 + scroll: StyleValue; 170 + }; 171 + 172 + // Text alignment utilities types 173 + export type TextAlignAtoms = { 174 + left: StyleValue; 175 + center: StyleValue; 176 + right: StyleValue; 177 + justify: StyleValue; 178 + auto: StyleValue; 179 + }; 180 + 181 + // Font weight utilities types 182 + export type FontWeightAtoms = { 183 + thin: StyleValue; 184 + extralight: StyleValue; 185 + light: StyleValue; 186 + normal: StyleValue; 187 + medium: StyleValue; 188 + semibold: StyleValue; 189 + bold: StyleValue; 190 + extrabold: StyleValue; 191 + black: StyleValue; 192 + }; 193 + 194 + // Layout utilities types 195 + export type LayoutAtoms = { 196 + flex: { 197 + center: StyleValue; 198 + row: StyleValue; 199 + column: StyleValue; 200 + spaceBetween: StyleValue; 201 + spaceAround: StyleValue; 202 + spaceEvenly: StyleValue; 203 + }; 204 + position: { 205 + absolute: StyleValue; 206 + relative: StyleValue; 207 + }; 208 + }; 209 + 210 + // Icon sizes types 211 + export type IconSizeAtoms = { 212 + sm: number; 213 + md: number; 214 + lg: number; 215 + xl: number; 216 + }; 217 + 218 + // Typography atoms types 219 + export type TypographyAtoms = { 220 + platform: Record<string, StyleValue>; 221 + universal: Record<string, StyleValue>; 222 + ios: Record<string, StyleValue>; 223 + android: Record<string, StyleValue>; 224 + }; 225 + 226 + // Main atoms object type 227 + export type Atoms = { 228 + colors: Colors; 229 + spacing: typeof import("./tokens").spacing; 230 + borderRadius: typeof import("./tokens").borderRadius; 231 + radius: RadiusAtoms; 232 + typography: TypographyAtoms; 233 + shadows: typeof import("./tokens").shadows; 234 + touchTargets: typeof import("./tokens").touchTargets; 235 + animations: typeof import("./tokens").animations; 236 + iconSizes: IconSizeAtoms; 237 + layout: LayoutAtoms; 238 + borders: BorderAtoms; 239 + backgrounds: PairifiedColors; 240 + textColors: PairifiedColors; 241 + spacingAtoms: SpacingAtoms; 242 + sizes: SizeAtoms; 243 + flex: FlexAtoms; 244 + opacity: OpacityAtoms; 245 + zIndex: ZIndexAtoms; 246 + overflow: OverflowAtoms; 247 + textAlign: TextAlignAtoms; 248 + fontWeight: FontWeightAtoms; 249 + }; 250 + 251 + // Shorthand aliases types 252 + export type BackgroundAtoms = PairifiedColors; 253 + export type TextColorAtoms = PairifiedColors; 254 + export type MarginAtoms = PairifiedSpacing; 255 + export type PaddingAtoms = PairifiedSpacing; 256 + export type WidthAtoms = PairifiedSpacing; 257 + export type HeightAtoms = PairifiedSpacing; 258 + export type BorderRadiusAllAtoms = PairifiedBorderRadius;
+48
js/components/src/lib/theme/index.ts
··· 1 + // Main theme system exports 2 + export { 3 + ThemeProvider, 4 + createThemeColors, 5 + createThemeIcons, 6 + createThemeStyles, 7 + createThemedStyles, 8 + darkTheme, 9 + lightTheme, 10 + usePlatformTypography, 11 + useTheme, 12 + type Theme, 13 + type ThemeIcons, 14 + type ThemeStyles, 15 + } from "./theme"; 16 + 17 + // Design tokens 18 + export { 19 + animations, 20 + borderRadius, 21 + breakpoints, 22 + colors, 23 + shadows, 24 + spacing, 25 + touchTargets, 26 + typography, 27 + type Animations, 28 + type BorderRadius, 29 + type Breakpoints, 30 + type Colors, 31 + type Shadows, 32 + type Spacing, 33 + type TouchTargets, 34 + type Typography, 35 + } from "./tokens"; 36 + 37 + // Utility atoms 38 + export { 39 + borders, 40 + getPlatformTypography, 41 + iconSizes, 42 + layout, 43 + typographyAtoms, 44 + } from "./atoms"; 45 + 46 + // Convenience re-exports 47 + export * as atoms from "./atoms"; 48 + export * as tokens from "./tokens";
+436
js/components/src/lib/theme/theme.tsx
··· 1 + import { PortalHost } from "@rn-primitives/portal"; 2 + import { 3 + createContext, 4 + useContext, 5 + useMemo, 6 + useState, 7 + type ReactNode, 8 + } from "react"; 9 + import { Platform, useColorScheme } from "react-native"; 10 + import { 11 + animations, 12 + borderRadius, 13 + colors, 14 + shadows, 15 + spacing, 16 + touchTargets, 17 + typography, 18 + } from "./tokens"; 19 + 20 + import { GestureHandlerRootView } from "react-native-gesture-handler"; 21 + 22 + // Theme interfaces 23 + export interface Theme { 24 + colors: { 25 + // Core semantic colors 26 + background: string; 27 + foreground: string; 28 + 29 + // Card/surface colors 30 + card: string; 31 + cardForeground: string; 32 + 33 + // Popover colors 34 + popover: string; 35 + popoverForeground: string; 36 + 37 + // Primary colors 38 + primary: string; 39 + primaryForeground: string; 40 + 41 + // Secondary colors 42 + secondary: string; 43 + secondaryForeground: string; 44 + 45 + // Muted colors 46 + muted: string; 47 + mutedForeground: string; 48 + 49 + // Accent colors 50 + accent: string; 51 + accentForeground: string; 52 + 53 + // Destructive colors 54 + destructive: string; 55 + destructiveForeground: string; 56 + 57 + // Success colors 58 + success: string; 59 + successForeground: string; 60 + 61 + // Warning colors 62 + warning: string; 63 + warningForeground: string; 64 + 65 + // Border and input colors 66 + border: string; 67 + input: string; 68 + ring: string; 69 + 70 + // Text colors 71 + text: string; 72 + textMuted: string; 73 + textDisabled: string; 74 + }; 75 + spacing: typeof spacing; 76 + borderRadius: typeof borderRadius; 77 + typography: typeof typography; 78 + shadows: typeof shadows; 79 + touchTargets: typeof touchTargets; 80 + animations: typeof animations; 81 + } 82 + 83 + // Utility styles interface 84 + export interface ThemeStyles { 85 + shadow: { 86 + sm: typeof shadows.sm; 87 + md: typeof shadows.md; 88 + lg: typeof shadows.lg; 89 + xl: typeof shadows.xl; 90 + }; 91 + button: { 92 + primary: object; 93 + secondary: object; 94 + outline: object; 95 + ghost: object; 96 + }; 97 + text: { 98 + primary: object; 99 + muted: object; 100 + disabled: object; 101 + }; 102 + input: { 103 + base: object; 104 + focused: object; 105 + error: object; 106 + }; 107 + card: { 108 + base: object; 109 + }; 110 + } 111 + 112 + // Icon utilities interface 113 + export interface ThemeIcons { 114 + color: { 115 + default: string; 116 + muted: string; 117 + primary: string; 118 + secondary: string; 119 + destructive: string; 120 + success: string; 121 + warning: string; 122 + }; 123 + size: { 124 + sm: number; 125 + md: number; 126 + lg: number; 127 + xl: number; 128 + }; 129 + } 130 + 131 + // Create theme colors based on dark mode 132 + const createThemeColors = (isDark: boolean): Theme["colors"] => ({ 133 + background: isDark ? colors.gray[950] : colors.white, 134 + foreground: isDark ? colors.gray[50] : colors.gray[950], 135 + 136 + card: isDark ? colors.gray[900] : colors.white, 137 + cardForeground: isDark ? colors.gray[50] : colors.gray[950], 138 + 139 + popover: isDark ? colors.gray[900] : colors.white, 140 + popoverForeground: isDark ? colors.gray[50] : colors.gray[950], 141 + 142 + primary: Platform.OS === "ios" ? colors.ios.systemBlue : colors.primary[500], 143 + primaryForeground: colors.white, 144 + 145 + secondary: isDark ? colors.gray[800] : colors.gray[100], 146 + secondaryForeground: isDark ? colors.gray[50] : colors.gray[900], 147 + 148 + muted: isDark ? colors.gray[800] : colors.gray[100], 149 + mutedForeground: isDark ? colors.gray[400] : colors.gray[500], 150 + 151 + accent: isDark ? colors.gray[800] : colors.gray[100], 152 + accentForeground: isDark ? colors.gray[50] : colors.gray[900], 153 + 154 + destructive: 155 + Platform.OS === "ios" ? colors.ios.systemRed : colors.destructive[500], 156 + destructiveForeground: colors.white, 157 + 158 + success: Platform.OS === "ios" ? colors.ios.systemGreen : colors.success[500], 159 + successForeground: colors.white, 160 + 161 + warning: 162 + Platform.OS === "ios" ? colors.ios.systemOrange : colors.warning[500], 163 + warningForeground: colors.white, 164 + 165 + border: isDark ? colors.gray[500] + "30" : colors.gray[200] + "30", 166 + input: isDark ? colors.gray[800] : colors.gray[200], 167 + ring: Platform.OS === "ios" ? colors.ios.systemBlue : colors.primary[500], 168 + 169 + text: isDark ? colors.gray[50] : colors.gray[950], 170 + textMuted: isDark ? colors.gray[400] : colors.gray[500], 171 + textDisabled: isDark ? colors.gray[600] : colors.gray[400], 172 + }); 173 + 174 + // Create theme styles based on colors 175 + const createThemeStyles = (themeColors: Theme["colors"]): ThemeStyles => ({ 176 + shadow: { 177 + sm: shadows.sm, 178 + md: shadows.md, 179 + lg: shadows.lg, 180 + xl: shadows.xl, 181 + }, 182 + button: { 183 + primary: { 184 + backgroundColor: themeColors.primary, 185 + borderWidth: 0, 186 + ...shadows.sm, 187 + }, 188 + secondary: { 189 + backgroundColor: themeColors.secondary, 190 + borderWidth: 0, 191 + }, 192 + outline: { 193 + backgroundColor: "transparent", 194 + borderWidth: 1, 195 + borderColor: themeColors.border, 196 + }, 197 + ghost: { 198 + backgroundColor: "transparent", 199 + borderWidth: 0, 200 + }, 201 + }, 202 + text: { 203 + primary: { 204 + color: themeColors.text, 205 + }, 206 + muted: { 207 + color: themeColors.textMuted, 208 + }, 209 + disabled: { 210 + color: themeColors.textDisabled, 211 + }, 212 + }, 213 + input: { 214 + base: { 215 + backgroundColor: themeColors.background, 216 + borderWidth: 1, 217 + borderColor: themeColors.border, 218 + borderRadius: borderRadius.md, 219 + paddingHorizontal: spacing[3], 220 + paddingVertical: spacing[3], 221 + minHeight: touchTargets.minimum, 222 + }, 223 + focused: { 224 + borderColor: themeColors.ring, 225 + borderWidth: 2, 226 + }, 227 + error: { 228 + borderColor: themeColors.destructive, 229 + borderWidth: 2, 230 + }, 231 + }, 232 + card: { 233 + base: { 234 + backgroundColor: themeColors.card, 235 + borderRadius: borderRadius.lg, 236 + ...shadows.sm, 237 + }, 238 + }, 239 + }); 240 + 241 + // Create theme icons based on colors 242 + const createThemeIcons = (themeColors: Theme["colors"]): ThemeIcons => ({ 243 + color: { 244 + default: themeColors.text, 245 + muted: themeColors.textMuted, 246 + primary: themeColors.primary, 247 + secondary: themeColors.secondary, 248 + destructive: themeColors.destructive, 249 + success: themeColors.success, 250 + warning: themeColors.warning, 251 + }, 252 + size: { 253 + sm: 16, 254 + md: 20, 255 + lg: 24, 256 + xl: 32, 257 + }, 258 + }); 259 + 260 + // Theme context interface 261 + interface ThemeContextType { 262 + theme: Theme; 263 + styles: ThemeStyles; 264 + icons: ThemeIcons; 265 + isDark: boolean; 266 + currentTheme: "light" | "dark" | "system"; 267 + systemTheme: "light" | "dark"; 268 + setTheme: (theme: "light" | "dark" | "system") => void; 269 + toggleTheme: () => void; 270 + } 271 + 272 + // Create the theme context 273 + const ThemeContext = createContext<ThemeContextType | null>(null); 274 + 275 + // Theme provider props 276 + interface ThemeProviderProps { 277 + children: ReactNode; 278 + defaultTheme?: "light" | "dark" | "system"; 279 + forcedTheme?: "light" | "dark"; 280 + } 281 + 282 + // Theme provider component 283 + export function ThemeProvider({ 284 + children, 285 + defaultTheme = "system", 286 + forcedTheme, 287 + }: ThemeProviderProps) { 288 + const systemColorScheme = useColorScheme(); 289 + const [currentTheme, setCurrentTheme] = useState<"light" | "dark" | "system">( 290 + defaultTheme, 291 + ); 292 + 293 + // Determine if dark mode should be active 294 + const isDark = useMemo(() => { 295 + if (forcedTheme === "light") return false; 296 + if (forcedTheme === "dark") return true; 297 + if (currentTheme === "light") return false; 298 + if (currentTheme === "dark") return true; 299 + if (currentTheme === "system") return systemColorScheme === "dark"; 300 + return systemColorScheme === "dark"; 301 + }, [forcedTheme, currentTheme, systemColorScheme]); 302 + 303 + // Create theme based on dark mode 304 + const theme = useMemo<Theme>(() => { 305 + const themeColors = createThemeColors(isDark); 306 + return { 307 + colors: themeColors, 308 + spacing, 309 + borderRadius, 310 + typography, 311 + shadows, 312 + touchTargets, 313 + animations, 314 + }; 315 + }, [isDark]); 316 + 317 + // Create utility styles 318 + const styles = useMemo<ThemeStyles>(() => { 319 + return createThemeStyles(theme.colors); 320 + }, [theme.colors]); 321 + 322 + // Create icon utilities 323 + const icons = useMemo<ThemeIcons>(() => { 324 + return createThemeIcons(theme.colors); 325 + }, [theme.colors]); 326 + 327 + // Theme controls 328 + const setTheme = (newTheme: "light" | "dark" | "system") => { 329 + if (!forcedTheme) { 330 + setCurrentTheme(newTheme); 331 + } 332 + }; 333 + 334 + const toggleTheme = () => { 335 + if (!forcedTheme) { 336 + setCurrentTheme((prev) => { 337 + if (prev === "light") return "dark"; 338 + if (prev === "dark") return "system"; 339 + return "light"; 340 + }); 341 + } 342 + }; 343 + 344 + const value = useMemo<ThemeContextType>( 345 + () => ({ 346 + theme, 347 + styles, 348 + icons, 349 + isDark, 350 + currentTheme: forcedTheme || currentTheme, 351 + systemTheme: (systemColorScheme as "light" | "dark") || "light", 352 + setTheme, 353 + toggleTheme, 354 + }), 355 + [ 356 + theme, 357 + styles, 358 + icons, 359 + isDark, 360 + forcedTheme, 361 + currentTheme, 362 + systemColorScheme, 363 + setTheme, 364 + toggleTheme, 365 + ], 366 + ); 367 + 368 + return ( 369 + <ThemeContext.Provider value={value}> 370 + <GestureHandlerRootView> 371 + {children} 372 + <PortalHost /> 373 + </GestureHandlerRootView> 374 + </ThemeContext.Provider> 375 + ); 376 + } 377 + 378 + // Hook to use theme 379 + export function useTheme(): ThemeContextType { 380 + const context = useContext(ThemeContext); 381 + if (!context) { 382 + throw new Error("useTheme must be used within a ThemeProvider"); 383 + } 384 + return context; 385 + } 386 + 387 + // Hook to get current platform's typography 388 + export function usePlatformTypography() { 389 + const { theme } = useTheme(); 390 + 391 + return useMemo(() => { 392 + if (Platform.OS === "ios") { 393 + return theme.typography.ios; 394 + } else if (Platform.OS === "android") { 395 + return theme.typography.android; 396 + } 397 + return theme.typography.universal; 398 + }, [theme.typography]); 399 + } 400 + 401 + // Utility function to create theme-aware styles 402 + export function createThemedStyles<T extends Record<string, any>>( 403 + styleCreator: (theme: Theme, styles: ThemeStyles, icons: ThemeIcons) => T, 404 + ) { 405 + return function useThemedStyles() { 406 + const { theme, styles, icons } = useTheme(); 407 + return useMemo( 408 + () => styleCreator(theme, styles, icons), 409 + [theme, styles, icons], 410 + ); 411 + }; 412 + } 413 + 414 + // Create light and dark theme instances for external use 415 + export const lightTheme: Theme = { 416 + colors: createThemeColors(false), 417 + spacing, 418 + borderRadius, 419 + typography, 420 + shadows, 421 + touchTargets, 422 + animations, 423 + }; 424 + 425 + export const darkTheme: Theme = { 426 + colors: createThemeColors(true), 427 + spacing, 428 + borderRadius, 429 + typography, 430 + shadows, 431 + touchTargets, 432 + animations, 433 + }; 434 + 435 + // Export individual theme utilities for convenience 436 + export { createThemeColors, createThemeIcons, createThemeStyles };
+409
js/components/src/lib/theme/tokens.ts
··· 1 + /** 2 + * Design tokens for React Native components 3 + * Inspired by shadcn/ui but adapted for React Native styling 4 + */ 5 + 6 + export const colors = { 7 + // Primary colors 8 + primary: { 9 + 50: "#eff6ff", 10 + 100: "#dbeafe", 11 + 200: "#bfdbfe", 12 + 300: "#93c5fd", 13 + 400: "#60a5fa", 14 + 500: "#3b82f6", 15 + 600: "#2563eb", 16 + 700: "#1d4ed8", 17 + 800: "#1e40af", 18 + 900: "#1e3a8a", 19 + 950: "#172554", 20 + }, 21 + 22 + // Grayscale 23 + gray: { 24 + 50: "#fafafa", 25 + 100: "#f5f5f5", 26 + 200: "#e5e5e5", 27 + 300: "#d4d4d4", 28 + 400: "#a3a3a3", 29 + 500: "#737373", 30 + 600: "#525252", 31 + 700: "#404040", 32 + 800: "#262626", 33 + 900: "#171717", 34 + 950: "#0d0d0d", 35 + }, 36 + 37 + // Semantic colors 38 + destructive: { 39 + 50: "#fef2f2", 40 + 100: "#fee2e2", 41 + 200: "#fecaca", 42 + 300: "#fca5a5", 43 + 400: "#f87171", 44 + 500: "#ef4444", 45 + 600: "#dc2626", 46 + 700: "#b91c1c", 47 + 800: "#991b1b", 48 + 900: "#7f1d1d", 49 + 950: "#450a0a", 50 + }, 51 + 52 + success: { 53 + 50: "#f0fdf4", 54 + 100: "#dcfce7", 55 + 200: "#bbf7d0", 56 + 300: "#86efac", 57 + 400: "#4ade80", 58 + 500: "#22c55e", 59 + 600: "#16a34a", 60 + 700: "#15803d", 61 + 800: "#166534", 62 + 900: "#14532d", 63 + 950: "#052e16", 64 + }, 65 + 66 + warning: { 67 + 50: "#fffbeb", 68 + 100: "#fef3c7", 69 + 200: "#fde68a", 70 + 300: "#fcd34d", 71 + 400: "#fbbf24", 72 + 500: "#f59e0b", 73 + 600: "#d97706", 74 + 700: "#b45309", 75 + 800: "#92400e", 76 + 900: "#78350f", 77 + 950: "#451a03", 78 + }, 79 + 80 + // iOS system colors (adaptive) 81 + ios: { 82 + systemBlue: "#007AFF", 83 + systemGreen: "#34C759", 84 + systemRed: "#FF3B30", 85 + systemOrange: "#FF9500", 86 + systemYellow: "#FFCC00", 87 + systemPurple: "#AF52DE", 88 + systemPink: "#FF2D92", 89 + systemTeal: "#5AC8FA", 90 + systemIndigo: "#5856D6", 91 + systemGray: "#8E8E93", 92 + systemGray2: "#AEAEB2", 93 + systemGray3: "#C7C7CC", 94 + systemGray4: "#D1D1D6", 95 + systemGray5: "#E5E5EA", 96 + systemGray6: "#F2F2F7", 97 + }, 98 + 99 + // Android Material colors 100 + android: { 101 + primary: "#6200EE", 102 + primaryVariant: "#3700B3", 103 + secondary: "#03DAC6", 104 + secondaryVariant: "#018786", 105 + background: "#FFFFFF", 106 + surface: "#FFFFFF", 107 + error: "#B00020", 108 + onPrimary: "#FFFFFF", 109 + onSecondary: "#000000", 110 + onBackground: "#000000", 111 + onSurface: "#000000", 112 + onError: "#FFFFFF", 113 + }, 114 + 115 + // Transparent colors 116 + transparent: "transparent", 117 + black: "#000000", 118 + white: "#FFFFFF", 119 + } as const; 120 + 121 + export const spacing = { 122 + 0: 0, 123 + 1: 4, 124 + 2: 8, 125 + 3: 12, 126 + 4: 16, 127 + 5: 20, 128 + 6: 24, 129 + 7: 28, 130 + 8: 32, 131 + 9: 36, 132 + 10: 40, 133 + 11: 44, 134 + 12: 48, 135 + 14: 56, 136 + 16: 64, 137 + 20: 80, 138 + 24: 96, 139 + 28: 112, 140 + 32: 128, 141 + 36: 144, 142 + 40: 160, 143 + 44: 176, 144 + 48: 192, 145 + 52: 208, 146 + 56: 224, 147 + 60: 240, 148 + 64: 256, 149 + 72: 288, 150 + 80: 320, 151 + 96: 384, 152 + auto: "auto", 153 + } as const; 154 + 155 + export const borderRadius = { 156 + none: 0, 157 + sm: 4, 158 + md: 8, 159 + lg: 12, 160 + xl: 16, 161 + "2xl": 20, 162 + "3xl": 24, 163 + full: 999, 164 + } as const; 165 + 166 + export const typography = { 167 + // iOS system font sizes 168 + ios: { 169 + largeTitle: { 170 + fontSize: 34, 171 + lineHeight: 41, 172 + fontWeight: "700" as const, 173 + }, 174 + title1: { 175 + fontSize: 28, 176 + lineHeight: 34, 177 + fontWeight: "700" as const, 178 + }, 179 + title2: { 180 + fontSize: 22, 181 + lineHeight: 28, 182 + fontWeight: "700" as const, 183 + }, 184 + title3: { 185 + fontSize: 20, 186 + lineHeight: 25, 187 + fontWeight: "600" as const, 188 + }, 189 + headline: { 190 + fontSize: 17, 191 + lineHeight: 22, 192 + fontWeight: "600" as const, 193 + }, 194 + body: { 195 + fontSize: 17, 196 + lineHeight: 22, 197 + fontWeight: "400" as const, 198 + }, 199 + callout: { 200 + fontSize: 16, 201 + lineHeight: 21, 202 + fontWeight: "400" as const, 203 + }, 204 + subhead: { 205 + fontSize: 15, 206 + lineHeight: 20, 207 + fontWeight: "400" as const, 208 + }, 209 + footnote: { 210 + fontSize: 13, 211 + lineHeight: 18, 212 + fontWeight: "400" as const, 213 + }, 214 + caption1: { 215 + fontSize: 12, 216 + lineHeight: 16, 217 + fontWeight: "400" as const, 218 + }, 219 + caption2: { 220 + fontSize: 11, 221 + lineHeight: 13, 222 + fontWeight: "400" as const, 223 + }, 224 + }, 225 + 226 + // Android Material typography 227 + android: { 228 + headline1: { 229 + fontSize: 96, 230 + lineHeight: 112, 231 + fontWeight: "300" as const, 232 + }, 233 + headline2: { 234 + fontSize: 60, 235 + lineHeight: 72, 236 + fontWeight: "300" as const, 237 + }, 238 + headline3: { 239 + fontSize: 48, 240 + lineHeight: 56, 241 + fontWeight: "400" as const, 242 + }, 243 + headline4: { 244 + fontSize: 34, 245 + lineHeight: 42, 246 + fontWeight: "400" as const, 247 + }, 248 + headline5: { 249 + fontSize: 24, 250 + lineHeight: 32, 251 + fontWeight: "400" as const, 252 + }, 253 + headline6: { 254 + fontSize: 20, 255 + lineHeight: 28, 256 + fontWeight: "500" as const, 257 + }, 258 + subtitle1: { 259 + fontSize: 16, 260 + lineHeight: 24, 261 + fontWeight: "400" as const, 262 + }, 263 + subtitle2: { 264 + fontSize: 14, 265 + lineHeight: 22, 266 + fontWeight: "500" as const, 267 + }, 268 + body1: { 269 + fontSize: 16, 270 + lineHeight: 24, 271 + fontWeight: "400" as const, 272 + }, 273 + body2: { 274 + fontSize: 14, 275 + lineHeight: 20, 276 + fontWeight: "400" as const, 277 + }, 278 + button: { 279 + fontSize: 14, 280 + lineHeight: 16, 281 + fontWeight: "500" as const, 282 + }, 283 + caption: { 284 + fontSize: 12, 285 + lineHeight: 16, 286 + fontWeight: "400" as const, 287 + }, 288 + overline: { 289 + fontSize: 10, 290 + lineHeight: 16, 291 + fontWeight: "400" as const, 292 + }, 293 + }, 294 + 295 + // Universal typography scale 296 + universal: { 297 + xs: { 298 + fontSize: 12, 299 + lineHeight: 16, 300 + fontWeight: "400" as const, 301 + }, 302 + sm: { 303 + fontSize: 14, 304 + lineHeight: 20, 305 + fontWeight: "400" as const, 306 + }, 307 + base: { 308 + fontSize: 16, 309 + lineHeight: 24, 310 + fontWeight: "400" as const, 311 + }, 312 + lg: { 313 + fontSize: 18, 314 + lineHeight: 28, 315 + fontWeight: "400" as const, 316 + }, 317 + xl: { 318 + fontSize: 20, 319 + lineHeight: 28, 320 + fontWeight: "500" as const, 321 + }, 322 + "2xl": { 323 + fontSize: 24, 324 + lineHeight: 32, 325 + fontWeight: "600" as const, 326 + }, 327 + "3xl": { 328 + fontSize: 30, 329 + lineHeight: 36, 330 + fontWeight: "700" as const, 331 + }, 332 + "4xl": { 333 + fontSize: 36, 334 + lineHeight: 40, 335 + fontWeight: "700" as const, 336 + }, 337 + }, 338 + } as const; 339 + 340 + export const shadows = { 341 + none: { 342 + shadowColor: "transparent", 343 + shadowOffset: { width: 0, height: 0 }, 344 + shadowOpacity: 0, 345 + shadowRadius: 0, 346 + elevation: 0, 347 + }, 348 + sm: { 349 + shadowColor: colors.black, 350 + shadowOffset: { width: 0, height: 1 }, 351 + shadowOpacity: 0.05, 352 + shadowRadius: 2, 353 + elevation: 2, 354 + }, 355 + md: { 356 + shadowColor: colors.black, 357 + shadowOffset: { width: 0, height: 2 }, 358 + shadowOpacity: 0.1, 359 + shadowRadius: 4, 360 + elevation: 4, 361 + }, 362 + lg: { 363 + shadowColor: colors.black, 364 + shadowOffset: { width: 0, height: 4 }, 365 + shadowOpacity: 0.15, 366 + shadowRadius: 8, 367 + elevation: 8, 368 + }, 369 + xl: { 370 + shadowColor: colors.black, 371 + shadowOffset: { width: 0, height: 8 }, 372 + shadowOpacity: 0.2, 373 + shadowRadius: 16, 374 + elevation: 16, 375 + }, 376 + } as const; 377 + 378 + // Touch targets (iOS Human Interface Guidelines) 379 + export const touchTargets = { 380 + minimum: 44, // Minimum touch target size 381 + comfortable: 48, // Comfortable touch target size 382 + large: 56, // Large touch target size 383 + } as const; 384 + 385 + // Animation durations 386 + export const animations = { 387 + fast: 150, 388 + normal: 200, 389 + slow: 300, 390 + slower: 500, 391 + } as const; 392 + 393 + // Breakpoints for responsive design 394 + export const breakpoints = { 395 + sm: 640, 396 + md: 768, 397 + lg: 1024, 398 + xl: 1280, 399 + "2xl": 1536, 400 + } as const; 401 + 402 + export type Colors = typeof colors; 403 + export type Spacing = typeof spacing; 404 + export type BorderRadius = typeof borderRadius; 405 + export type Typography = typeof typography; 406 + export type Shadows = typeof shadows; 407 + export type TouchTargets = typeof touchTargets; 408 + export type Animations = typeof animations; 409 + export type Breakpoints = typeof breakpoints;
+132
js/components/src/lib/utils.ts
··· 1 + import { ImageStyle, StyleSheet, TextStyle, ViewStyle } from "react-native"; 2 + 3 + // React Native style utilities 4 + type Style = ViewStyle | TextStyle | ImageStyle; 5 + 6 + /** 7 + * Merges React Native styles similar to how cn() merges CSS classes 8 + * Handles arrays, objects, and falsy values 9 + */ 10 + export function mergeStyles( 11 + ...styles: (Style | Style[] | undefined | null | false)[] 12 + ): Style { 13 + const validStyles = styles.filter(Boolean).flat() as Style[]; 14 + return StyleSheet.flatten(validStyles) || {}; 15 + } 16 + 17 + /** 18 + * Creates a style merger function that includes base styles 19 + * Useful for component variants 20 + */ 21 + export function createStyleMerger(baseStyle: Style) { 22 + return (...styles: (Style | Style[] | undefined | null | false)[]) => { 23 + return mergeStyles(baseStyle, ...styles); 24 + }; 25 + } 26 + 27 + /** 28 + * Conditionally applies styles based on boolean conditions 29 + */ 30 + export function conditionalStyle( 31 + condition: boolean, 32 + trueStyle: Style, 33 + falseStyle?: Style, 34 + ): Style | undefined { 35 + return condition ? trueStyle : falseStyle; 36 + } 37 + 38 + /** 39 + * Creates responsive values based on screen dimensions 40 + */ 41 + export function responsiveValue<T>( 42 + values: { 43 + sm?: T; 44 + md?: T; 45 + lg?: T; 46 + xl?: T; 47 + default: T; 48 + }, 49 + screenWidth: number, 50 + ): T { 51 + if (screenWidth >= 1280 && values.xl !== undefined) return values.xl; 52 + if (screenWidth >= 1024 && values.lg !== undefined) return values.lg; 53 + if (screenWidth >= 768 && values.md !== undefined) return values.md; 54 + if (screenWidth >= 640 && values.sm !== undefined) return values.sm; 55 + return values.default; 56 + } 57 + 58 + /** 59 + * Creates platform-specific styles 60 + */ 61 + export function platformStyle(styles: { 62 + ios?: Style; 63 + android?: Style; 64 + web?: Style; 65 + default?: Style; 66 + }): Style { 67 + const Platform = require("react-native").Platform; 68 + 69 + if (Platform.OS === "ios" && styles.ios) return styles.ios; 70 + if (Platform.OS === "android" && styles.android) return styles.android; 71 + if (Platform.OS === "web" && styles.web) return styles.web; 72 + return styles.default || {}; 73 + } 74 + 75 + /** 76 + * Converts hex color to rgba 77 + */ 78 + export function hexToRgba(hex: string, alpha: number = 1): string { 79 + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 80 + if (!result) return hex; 81 + 82 + const r = parseInt(result[1], 16); 83 + const g = parseInt(result[2], 16); 84 + const b = parseInt(result[3], 16); 85 + 86 + return `rgba(${r}, ${g}, ${b}, ${alpha})`; 87 + } 88 + 89 + /** 90 + * Creates a debounced function for performance 91 + */ 92 + export function debounce<T extends (...args: any[]) => any>( 93 + func: T, 94 + delay: number, 95 + ): (...args: Parameters<T>) => void { 96 + let timeoutId: NodeJS.Timeout; 97 + 98 + return (...args: Parameters<T>) => { 99 + clearTimeout(timeoutId); 100 + timeoutId = setTimeout(() => func(...args), delay); 101 + }; 102 + } 103 + 104 + /** 105 + * Creates a throttled function for performance 106 + */ 107 + export function throttle<T extends (...args: any[]) => any>( 108 + func: T, 109 + delay: number, 110 + ): (...args: Parameters<T>) => void { 111 + let lastCall = 0; 112 + 113 + return (...args: Parameters<T>) => { 114 + const now = Date.now(); 115 + if (now - lastCall >= delay) { 116 + lastCall = now; 117 + func(...args); 118 + } 119 + }; 120 + } 121 + 122 + /** 123 + * Type-safe component prop forwarding 124 + */ 125 + export function forwardProps<T extends Record<string, any>>( 126 + props: T, 127 + omit: (keyof T)[], 128 + ): Omit<T, keyof T extends string ? keyof T : never> { 129 + const result = { ...props }; 130 + omit.forEach((key) => delete result[key]); 131 + return result; 132 + }
+1
js/components/src/livestream-store/index.tsx
··· 1 1 export * from "./chat"; 2 2 export * from "./context"; 3 3 export * from "./livestream-store"; 4 + export * from "./stream-key";
+2
js/components/src/livestream-store/livestream-state.tsx
··· 15 15 segment: PlaceStreamSegment.Record | null; 16 16 renditions: PlaceStreamDefs.Rendition[]; 17 17 replyToMessage: ChatMessageViewHydrated | null; 18 + streamKey: string | null; 19 + setStreamKey: (key: string | null) => void; 18 20 }
+2
js/components/src/livestream-store/livestream-store.tsx
··· 16 16 segment: null, 17 17 renditions: [], 18 18 replyToMessage: null, 19 + streamKey: null, 20 + setStreamKey: (sk) => set({ streamKey: sk }), 19 21 })); 20 22 }; 21 23
+95
js/components/src/livestream-store/stream-key.tsx
··· 1 + import { bytesToMultibase, Secp256k1Keypair } from "@atproto/crypto"; 2 + import { useEffect, useState } from "react"; 3 + import { Platform } from "react-native"; 4 + import { PlaceStreamKey } from "streamplace"; 5 + import { privateKeyToAccount } from "viem/accounts"; 6 + import { usePDSAgent } from "../streamplace-store/xrpc"; 7 + import { useLivestreamStore } from "./livestream-store"; 8 + 9 + export const useStreamKey = (): { 10 + streamKey: { 11 + privateKey: string; 12 + did: string; 13 + address: string; 14 + } | null; 15 + error: string | null; 16 + } => { 17 + const pdsAgent = usePDSAgent(); 18 + const streamKey = useLivestreamStore((state) => state.streamKey); 19 + const setStreamKey = useLivestreamStore((state) => state.setStreamKey); 20 + const [key, setKey] = useState<any>(streamKey ? JSON.parse(streamKey) : null); 21 + const [error, setError] = useState<string | null>(null); 22 + 23 + useEffect(() => { 24 + if (key) return; // already have key 25 + 26 + const generateKey = async () => { 27 + if (!pdsAgent) { 28 + setError("PDS Agent is not available"); 29 + return; 30 + } 31 + let did = pdsAgent.did; 32 + if (!did) { 33 + setError("PDS Agent did is not available (not logged in?)"); 34 + return; 35 + } 36 + 37 + const keypair = await Secp256k1Keypair.create({ exportable: true }); 38 + const exportedKey = await keypair.export(); 39 + const didBytes = new TextEncoder().encode(did); 40 + const combinedKey = new Uint8Array([...exportedKey, ...didBytes]); 41 + const multibaseKey = bytesToMultibase(combinedKey, "base58btc"); 42 + const hexKey = Array.from(exportedKey) 43 + .map((b) => b.toString(16).padStart(2, "0")) 44 + .join(""); 45 + const account = privateKeyToAccount(`0x${hexKey}`); 46 + const newKey = { 47 + privateKey: multibaseKey, 48 + did: keypair.did(), 49 + address: account.address.toLowerCase(), 50 + }; 51 + 52 + let platform: string = Platform.OS; 53 + if ( 54 + Platform.OS === "web" && 55 + typeof window !== "undefined" && 56 + window.navigator 57 + ) { 58 + let splitUA = window.navigator.userAgent 59 + .split(" ") 60 + .pop() 61 + ?.split("/")[0]; 62 + if (splitUA) { 63 + platform = splitUA; 64 + } 65 + } else if (platform === "android") { 66 + platform = "Android"; 67 + } else if (platform === "ios") { 68 + platform = "iOS"; 69 + } else if (platform === "macos") { 70 + platform = "macOS"; 71 + } else if (platform === "windows") { 72 + platform = "Windows"; 73 + } 74 + 75 + const record: PlaceStreamKey.Record = { 76 + signingKey: keypair.did(), 77 + createdAt: new Date().toISOString(), 78 + createdBy: "Streamplace on " + platform, 79 + }; 80 + await pdsAgent.com.atproto.repo.createRecord({ 81 + repo: did, 82 + collection: "place.stream.key", 83 + record, 84 + }); 85 + 86 + setStreamKey(JSON.stringify(newKey)); 87 + setKey(newKey); 88 + }; 89 + 90 + generateKey(); 91 + // eslint-disable-next-line react-hooks/exhaustive-deps 92 + }, [key, setStreamKey]); 93 + 94 + return { streamKey: key, error }; 95 + };
-1
js/components/src/player-store/player-provider.tsx
··· 33 33 ); 34 34 35 35 const createPlayer = useCallback((id?: string) => { 36 - console.log("Creating new player"); 37 36 const playerId = id || Math.random().toString(36).slice(8); 38 37 const playerStore = makePlayerStore(playerId); 39 38
+16
js/components/src/player-store/player-state.tsx
··· 50 50 ingestMediaSource?: IngestMediaSource; 51 51 setIngestMediaSource?: (source: IngestMediaSource) => void; 52 52 53 + ingestCamera: "user" | "environment"; 54 + setIngestCamera: (camera: "user" | "environment") => void; 55 + 53 56 ingestAutoStart?: boolean; 54 57 setIngestAutoStart?: (autoStart: boolean) => void; 55 58 ··· 110 113 | undefined, 111 114 ) => void; 112 115 116 + /** Player element width (CSS value or number) */ 117 + playerWidth?: string | number; 118 + /** Function to set the player width */ 119 + setPlayerWidth: (width: number) => void; 120 + 121 + /** Player element height (CSS value or number) */ 122 + playerHeight?: string | number; 123 + /** Function to set the player height */ 124 + setPlayerHeight: (height: number) => void; 125 + 113 126 /** Flag indicating if player is in Picture-in-Picture mode */ 114 127 pipMode: boolean; 115 128 ··· 148 161 clearControlsTimeout: () => void; 149 162 150 163 setUserInteraction: () => void; 164 + 165 + showDebugInfo: boolean; 166 + setShowDebugInfo: (showDebugInfo: boolean) => void; 151 167 } 152 168 153 169 export type PlayerEvent = {
+14
js/components/src/player-store/player-store.tsx
··· 32 32 setIngestMediaSource: (ingestMediaSource: IngestMediaSource | undefined) => 33 33 set(() => ({ ingestMediaSource })), 34 34 35 + ingestCamera: "user", 36 + setIngestCamera: (ingestCamera: "user" | "environment") => 37 + set(() => ({ ingestCamera })), 38 + 35 39 ingestConnectionState: null, 36 40 setIngestConnectionState: ( 37 41 ingestConnectionState: RTCPeerConnectionState | null, ··· 77 81 78 82 pipMode: false, 79 83 setPipMode: (pipMode: boolean) => set(() => ({ pipMode })), 84 + 85 + // Player element width/height setters for global sync 86 + playerWidth: undefined, 87 + setPlayerWidth: (playerWidth: number) => set(() => ({ playerWidth })), 88 + playerHeight: undefined, 89 + setPlayerHeight: (playerHeight: number) => set(() => ({ playerHeight })), 80 90 81 91 // * Whether mute was forced by the browser or not for autoplay 82 92 // * Will get set to 'false' if the user has interacted with the volume ··· 141 151 let controlsTimeout = setTimeout(() => p.setShowControls(false), 1000); 142 152 return { showControls: true, controlsTimeout }; 143 153 }), 154 + 155 + showDebugInfo: false, 156 + setShowDebugInfo: (showDebugInfo: boolean) => 157 + set(() => ({ showDebugInfo })), 144 158 })); 145 159 }; 146 160
+909
pnpm-lock.yaml
··· 386 386 '@atproto/api': 387 387 specifier: ^0.15.7 388 388 version: 0.15.7 389 + '@atproto/crypto': 390 + specifier: ^0.4.4 391 + version: 0.4.4 392 + '@gorhom/bottom-sheet': 393 + specifier: ^5.1.6 394 + version: 5.1.6(@types/react@18.3.12)(react-native-gesture-handler@2.20.2(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native-reanimated@3.16.7(@babel/core@7.26.0)(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 395 + '@rn-primitives/dropdown-menu': 396 + specifier: ^1.2.0 397 + version: 1.2.0(@rn-primitives/portal@1.3.0(@types/react@18.3.12)(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 398 + '@rn-primitives/portal': 399 + specifier: ^1.3.0 400 + version: 1.3.0(@types/react@18.3.12)(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)) 401 + class-variance-authority: 402 + specifier: ^0.6.1 403 + version: 0.6.1 404 + lucide-react-native: 405 + specifier: ^0.514.0 406 + version: 0.514.0(react-native-svg@15.12.0(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 389 407 react: 390 408 specifier: '*' 391 409 version: 19.0.0 392 410 react-native: 393 411 specifier: 0.76.2 394 412 version: 0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10) 413 + react-native-gesture-handler: 414 + specifier: ~2.20.2 415 + version: 2.20.2(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 416 + react-native-reanimated: 417 + specifier: ~3.16.1 418 + version: 3.16.7(@babel/core@7.26.0)(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 419 + react-native-safe-area-context: 420 + specifier: 4.12.0 421 + version: 4.12.0(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 395 422 react-use-websocket: 396 423 specifier: ^4.13.0 397 424 version: 4.13.0 398 425 streamplace: 399 426 specifier: workspace:* 400 427 version: link:../streamplace 428 + viem: 429 + specifier: ^2.21.40 430 + version: 2.21.44(bufferutil@4.0.8)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.24.4) 401 431 zustand: 402 432 specifier: ^5.0.5 403 433 version: 5.0.5(@types/react@18.3.12)(immer@10.1.1)(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)) ··· 2432 2462 resolution: {integrity: sha512-dFAR/IRENn+ZTTwBbMgoBGSrPrqNKoCEIjG7Wmq2+IpmyyjDk5BLip9HG9TUdMVRRP6xOQFrkEr7zIY1ZsoTSQ==} 2433 2463 engines: {node: '>=14'} 2434 2464 2465 + '@gorhom/bottom-sheet@5.1.6': 2466 + resolution: {integrity: sha512-0b5tQj4fTaZAjST1PnkCp0p7d8iRqMezibTcqc8Kkn3N23Vn6upORNTD1fH0bLfwRt6e0WnZ7DjAmq315lrcKQ==} 2467 + peerDependencies: 2468 + '@types/react': '*' 2469 + '@types/react-native': '*' 2470 + react: '*' 2471 + react-native: '*' 2472 + react-native-gesture-handler: '>=2.16.1' 2473 + react-native-reanimated: '>=3.16.0' 2474 + peerDependenciesMeta: 2475 + '@types/react': 2476 + optional: true 2477 + '@types/react-native': 2478 + optional: true 2479 + 2480 + '@gorhom/portal@1.0.14': 2481 + resolution: {integrity: sha512-MXyL4xvCjmgaORr/rtryDNFy3kU4qUbKlwtQqqsygd0xX3mhKjOLn6mQK8wfu0RkoE0pBE0nAasRoHua+/QZ7A==} 2482 + peerDependencies: 2483 + react: '*' 2484 + react-native: '*' 2485 + 2435 2486 '@grpc/grpc-js@1.10.10': 2436 2487 resolution: {integrity: sha512-HPa/K5NX6ahMoeBv15njAc/sfF4/jmiXLar9UlC2UfHFKZzsCVLc3wbe7+7qua7w9VPh2/L6EBxyAV7/E8Wftg==} 2437 2488 engines: {node: '>=12.10.0'} ··· 3085 3136 '@protobufjs/utf8@1.1.0': 3086 3137 resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} 3087 3138 3139 + '@radix-ui/primitive@1.1.2': 3140 + resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} 3141 + 3142 + '@radix-ui/react-arrow@1.1.7': 3143 + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} 3144 + peerDependencies: 3145 + '@types/react': '*' 3146 + '@types/react-dom': '*' 3147 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3148 + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3149 + peerDependenciesMeta: 3150 + '@types/react': 3151 + optional: true 3152 + '@types/react-dom': 3153 + optional: true 3154 + 3155 + '@radix-ui/react-collection@1.1.7': 3156 + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} 3157 + peerDependencies: 3158 + '@types/react': '*' 3159 + '@types/react-dom': '*' 3160 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3161 + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3162 + peerDependenciesMeta: 3163 + '@types/react': 3164 + optional: true 3165 + '@types/react-dom': 3166 + optional: true 3167 + 3168 + '@radix-ui/react-compose-refs@1.1.2': 3169 + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} 3170 + peerDependencies: 3171 + '@types/react': '*' 3172 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3173 + peerDependenciesMeta: 3174 + '@types/react': 3175 + optional: true 3176 + 3177 + '@radix-ui/react-context@1.1.2': 3178 + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} 3179 + peerDependencies: 3180 + '@types/react': '*' 3181 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3182 + peerDependenciesMeta: 3183 + '@types/react': 3184 + optional: true 3185 + 3186 + '@radix-ui/react-direction@1.1.1': 3187 + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} 3188 + peerDependencies: 3189 + '@types/react': '*' 3190 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3191 + peerDependenciesMeta: 3192 + '@types/react': 3193 + optional: true 3194 + 3195 + '@radix-ui/react-dismissable-layer@1.1.10': 3196 + resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==} 3197 + peerDependencies: 3198 + '@types/react': '*' 3199 + '@types/react-dom': '*' 3200 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3201 + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3202 + peerDependenciesMeta: 3203 + '@types/react': 3204 + optional: true 3205 + '@types/react-dom': 3206 + optional: true 3207 + 3208 + '@radix-ui/react-dropdown-menu@2.1.15': 3209 + resolution: {integrity: sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==} 3210 + peerDependencies: 3211 + '@types/react': '*' 3212 + '@types/react-dom': '*' 3213 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3214 + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3215 + peerDependenciesMeta: 3216 + '@types/react': 3217 + optional: true 3218 + '@types/react-dom': 3219 + optional: true 3220 + 3221 + '@radix-ui/react-focus-guards@1.1.2': 3222 + resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==} 3223 + peerDependencies: 3224 + '@types/react': '*' 3225 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3226 + peerDependenciesMeta: 3227 + '@types/react': 3228 + optional: true 3229 + 3230 + '@radix-ui/react-focus-scope@1.1.7': 3231 + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} 3232 + peerDependencies: 3233 + '@types/react': '*' 3234 + '@types/react-dom': '*' 3235 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3236 + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3237 + peerDependenciesMeta: 3238 + '@types/react': 3239 + optional: true 3240 + '@types/react-dom': 3241 + optional: true 3242 + 3243 + '@radix-ui/react-id@1.1.1': 3244 + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} 3245 + peerDependencies: 3246 + '@types/react': '*' 3247 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3248 + peerDependenciesMeta: 3249 + '@types/react': 3250 + optional: true 3251 + 3252 + '@radix-ui/react-menu@2.1.15': 3253 + resolution: {integrity: sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==} 3254 + peerDependencies: 3255 + '@types/react': '*' 3256 + '@types/react-dom': '*' 3257 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3258 + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3259 + peerDependenciesMeta: 3260 + '@types/react': 3261 + optional: true 3262 + '@types/react-dom': 3263 + optional: true 3264 + 3265 + '@radix-ui/react-popper@1.2.7': 3266 + resolution: {integrity: sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==} 3267 + peerDependencies: 3268 + '@types/react': '*' 3269 + '@types/react-dom': '*' 3270 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3271 + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3272 + peerDependenciesMeta: 3273 + '@types/react': 3274 + optional: true 3275 + '@types/react-dom': 3276 + optional: true 3277 + 3278 + '@radix-ui/react-portal@1.1.9': 3279 + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} 3280 + peerDependencies: 3281 + '@types/react': '*' 3282 + '@types/react-dom': '*' 3283 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3284 + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3285 + peerDependenciesMeta: 3286 + '@types/react': 3287 + optional: true 3288 + '@types/react-dom': 3289 + optional: true 3290 + 3291 + '@radix-ui/react-presence@1.1.4': 3292 + resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} 3293 + peerDependencies: 3294 + '@types/react': '*' 3295 + '@types/react-dom': '*' 3296 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3297 + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3298 + peerDependenciesMeta: 3299 + '@types/react': 3300 + optional: true 3301 + '@types/react-dom': 3302 + optional: true 3303 + 3304 + '@radix-ui/react-primitive@2.1.3': 3305 + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} 3306 + peerDependencies: 3307 + '@types/react': '*' 3308 + '@types/react-dom': '*' 3309 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3310 + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3311 + peerDependenciesMeta: 3312 + '@types/react': 3313 + optional: true 3314 + '@types/react-dom': 3315 + optional: true 3316 + 3317 + '@radix-ui/react-roving-focus@1.1.10': 3318 + resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==} 3319 + peerDependencies: 3320 + '@types/react': '*' 3321 + '@types/react-dom': '*' 3322 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3323 + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3324 + peerDependenciesMeta: 3325 + '@types/react': 3326 + optional: true 3327 + '@types/react-dom': 3328 + optional: true 3329 + 3330 + '@radix-ui/react-slot@1.2.3': 3331 + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} 3332 + peerDependencies: 3333 + '@types/react': '*' 3334 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3335 + peerDependenciesMeta: 3336 + '@types/react': 3337 + optional: true 3338 + 3339 + '@radix-ui/react-use-callback-ref@1.1.1': 3340 + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} 3341 + peerDependencies: 3342 + '@types/react': '*' 3343 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3344 + peerDependenciesMeta: 3345 + '@types/react': 3346 + optional: true 3347 + 3348 + '@radix-ui/react-use-controllable-state@1.2.2': 3349 + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} 3350 + peerDependencies: 3351 + '@types/react': '*' 3352 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3353 + peerDependenciesMeta: 3354 + '@types/react': 3355 + optional: true 3356 + 3357 + '@radix-ui/react-use-effect-event@0.0.2': 3358 + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} 3359 + peerDependencies: 3360 + '@types/react': '*' 3361 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3362 + peerDependenciesMeta: 3363 + '@types/react': 3364 + optional: true 3365 + 3366 + '@radix-ui/react-use-escape-keydown@1.1.1': 3367 + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} 3368 + peerDependencies: 3369 + '@types/react': '*' 3370 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3371 + peerDependenciesMeta: 3372 + '@types/react': 3373 + optional: true 3374 + 3375 + '@radix-ui/react-use-layout-effect@1.1.1': 3376 + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} 3377 + peerDependencies: 3378 + '@types/react': '*' 3379 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3380 + peerDependenciesMeta: 3381 + '@types/react': 3382 + optional: true 3383 + 3384 + '@radix-ui/react-use-rect@1.1.1': 3385 + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} 3386 + peerDependencies: 3387 + '@types/react': '*' 3388 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3389 + peerDependenciesMeta: 3390 + '@types/react': 3391 + optional: true 3392 + 3393 + '@radix-ui/react-use-size@1.1.1': 3394 + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} 3395 + peerDependencies: 3396 + '@types/react': '*' 3397 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 3398 + peerDependenciesMeta: 3399 + '@types/react': 3400 + optional: true 3401 + 3402 + '@radix-ui/rect@1.1.1': 3403 + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} 3404 + 3088 3405 '@react-native-firebase/app@22.2.1': 3089 3406 resolution: {integrity: sha512-FNekjZgvLLBWLNMGKfKUHR4TTu8OY1YnvlQV9HaNboF5U5+CvTcK8IKycXKdhVP8YfcILTaM3AFraoQsQsy3/w==} 3090 3407 peerDependencies: ··· 3352 3669 version: 4.0.2 3353 3670 engines: {node: '>=19.0.0 || ^18.11.0'} 3354 3671 3672 + '@rn-primitives/dropdown-menu@1.2.0': 3673 + resolution: {integrity: sha512-TJDDr8VQfw9CRZ7xZ6kBYLVMqL1xFVC5ZZ4sfRmWP6PCT0lNks4XqGuTFLeVVlNLPSmzt9GKC2DZqzDXui8/NQ==} 3674 + peerDependencies: 3675 + '@rn-primitives/portal': '*' 3676 + react: '*' 3677 + react-native: '*' 3678 + react-native-web: '*' 3679 + peerDependenciesMeta: 3680 + react-native: 3681 + optional: true 3682 + react-native-web: 3683 + optional: true 3684 + 3685 + '@rn-primitives/hooks@1.3.0': 3686 + resolution: {integrity: sha512-BR97reSu7uVDpyMeQdRJHT0w8KdS6jdYnOL6xQtqS2q3H6N7vXBlX4LFERqJZphD+aziJFIAJ3HJF1vtt6XlpQ==} 3687 + peerDependencies: 3688 + react: '*' 3689 + react-native: '*' 3690 + react-native-web: '*' 3691 + peerDependenciesMeta: 3692 + react-native: 3693 + optional: true 3694 + react-native-web: 3695 + optional: true 3696 + 3697 + '@rn-primitives/portal@1.3.0': 3698 + resolution: {integrity: sha512-a2DSce7TcSfcs0cCngLadAJOvx/+mdH9NRu+GxkX8NPRsGGhJvDEOqouMgDqLwx7z9mjXoUaZcwaVcemUSW9/A==} 3699 + peerDependencies: 3700 + react: '*' 3701 + react-native: '*' 3702 + react-native-web: '*' 3703 + peerDependenciesMeta: 3704 + react-native: 3705 + optional: true 3706 + react-native-web: 3707 + optional: true 3708 + 3709 + '@rn-primitives/slot@1.2.0': 3710 + resolution: {integrity: sha512-cpbn+JLjSeq3wcA4uqgFsUimMrWYWx2Ks7r5rkwd1ds1utxynsGkLOKpYVQkATwWrYhtcoF1raxIKEqXuMN+/w==} 3711 + peerDependencies: 3712 + react: '*' 3713 + react-native: '*' 3714 + react-native-web: '*' 3715 + peerDependenciesMeta: 3716 + react-native: 3717 + optional: true 3718 + react-native-web: 3719 + optional: true 3720 + 3721 + '@rn-primitives/types@1.2.0': 3722 + resolution: {integrity: sha512-b+6zKgdKVqAfaFPSfhwlQL0dnPQXPpW890m3eguC0VDI1eOsoEvUfVb6lmgH4bum9MmI0xymq4tOUI/fsKLoCQ==} 3723 + peerDependencies: 3724 + react: '*' 3725 + react-native: '*' 3726 + react-native-web: '*' 3727 + peerDependenciesMeta: 3728 + react-native: 3729 + optional: true 3730 + react-native-web: 3731 + optional: true 3732 + 3733 + '@rn-primitives/utils@1.2.0': 3734 + resolution: {integrity: sha512-vLXV5NuxIHDeb4Bw57FzdUh89/g8gz6GERm8TsbJaSUPsDXfnC/ffeYiZJb0LxNteKE3Nr8na4Jy2n26tFil7w==} 3735 + peerDependencies: 3736 + react: '*' 3737 + react-native: '*' 3738 + react-native-web: '*' 3739 + peerDependenciesMeta: 3740 + react-native: 3741 + optional: true 3742 + react-native-web: 3743 + optional: true 3744 + 3355 3745 '@rollup/pluginutils@5.1.4': 3356 3746 resolution: {integrity: sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==} 3357 3747 engines: {node: '>=14.0.0'} ··· 5503 5893 resolution: {integrity: sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==} 5504 5894 engines: {node: '>=8'} 5505 5895 5896 + class-variance-authority@0.6.1: 5897 + resolution: {integrity: sha512-eurOEGc7YVx3majOrOb099PNKgO3KnKSApOprXI4BTq6bcfbqbQXPN2u+rPPmIJ2di23bMwhk0SxCCthBmszEQ==} 5898 + 5506 5899 clean-css@5.3.3: 5507 5900 resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==} 5508 5901 engines: {node: '>= 10.0'} ··· 5575 5968 clone@2.1.2: 5576 5969 resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==} 5577 5970 engines: {node: '>=0.8'} 5971 + 5972 + clsx@1.2.1: 5973 + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} 5974 + engines: {node: '>=6'} 5578 5975 5579 5976 clsx@2.1.1: 5580 5977 resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} ··· 8490 8887 lru-memoizer@2.3.0: 8491 8888 resolution: {integrity: sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==} 8492 8889 8890 + lucide-react-native@0.514.0: 8891 + resolution: {integrity: sha512-IaW5ItnJ9TDv9lZJOtjBRc2r217BRO4xNhd9g1hmW5GKvOtFzFYYfkogktUihiniXThwo1NBmNMa82mbWKMJVQ==} 8892 + peerDependencies: 8893 + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 8894 + react-native: '*' 8895 + react-native-svg: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 8896 + 8493 8897 macos-alias@0.2.11: 8494 8898 resolution: {integrity: sha512-zIUs3+qpml+w3wiRuADutd7XIO8UABqksot10Utl/tji4UxZzLG4fWDC+yJZoO8/Ehg5RqsvSRE/6TS5AEOeWw==} 8495 8899 os: [darwin] ··· 10068 10472 react-native-fit-image@1.5.5: 10069 10473 resolution: {integrity: sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==} 10070 10474 10475 + react-native-gesture-handler@2.20.2: 10476 + resolution: {integrity: sha512-HqzFpFczV4qCnwKlvSAvpzEXisL+Z9fsR08YV5LfJDkzuArMhBu2sOoSPUF/K62PCoAb+ObGlTC83TKHfUd0vg==} 10477 + peerDependencies: 10478 + react: '*' 10479 + react-native: '*' 10480 + 10071 10481 react-native-gesture-handler@2.26.0: 10072 10482 resolution: {integrity: sha512-pfE1j9Vzu0qpWj/Aq1IK+cYnougN69mCKvWuq1rdNjH2zs1WIszF0Mum9/oGQTemgjyc/JgiqOOTgwcleAMAGg==} 10073 10483 peerDependencies: ··· 10095 10505 react-native-quick-crypto@0.7.14: 10096 10506 resolution: {integrity: sha512-ePl0pNgw0TCl9sn9zVX6es58PHXIA6pdDm5+dHawypD+cacyvzfpAFEqYR6opFtnBff/HHtsQrS0zX0AAfwodQ==} 10097 10507 10508 + react-native-reanimated@3.16.7: 10509 + resolution: {integrity: sha512-qoUUQOwE1pHlmQ9cXTJ2MX9FQ9eHllopCLiWOkDkp6CER95ZWeXhJCP4cSm6AD4jigL5jHcZf/SkWrg8ttZUsw==} 10510 + peerDependencies: 10511 + '@babel/core': ^7.0.0-0 10512 + react: '*' 10513 + react-native: '*' 10514 + 10098 10515 react-native-reanimated@3.18.0: 10099 10516 resolution: {integrity: sha512-eVcNcqeOkMW+BUWAHdtvN3FKgC8J8wiEJkX6bNGGQaLS7m7e4amTfjIcqf/Ta+lerZLurmDaQ0lICI1CKPrb1Q==} 10100 10517 peerDependencies: 10101 10518 '@babel/core': ^7.0.0-0 10519 + react: '*' 10520 + react-native: '*' 10521 + 10522 + react-native-safe-area-context@4.12.0: 10523 + resolution: {integrity: sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ==} 10524 + peerDependencies: 10102 10525 react: '*' 10103 10526 react-native: '*' 10104 10527 ··· 10190 10613 '@types/react': 10191 10614 optional: true 10192 10615 10616 + react-remove-scroll-bar@2.3.8: 10617 + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} 10618 + engines: {node: '>=10'} 10619 + peerDependencies: 10620 + '@types/react': '*' 10621 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 10622 + peerDependenciesMeta: 10623 + '@types/react': 10624 + optional: true 10625 + 10193 10626 react-remove-scroll@2.6.0: 10194 10627 resolution: {integrity: sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==} 10195 10628 engines: {node: '>=10'} ··· 10200 10633 '@types/react': 10201 10634 optional: true 10202 10635 10636 + react-remove-scroll@2.7.1: 10637 + resolution: {integrity: sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==} 10638 + engines: {node: '>=10'} 10639 + peerDependencies: 10640 + '@types/react': '*' 10641 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc 10642 + peerDependenciesMeta: 10643 + '@types/react': 10644 + optional: true 10645 + 10203 10646 react-style-singleton@2.2.1: 10204 10647 resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} 10205 10648 engines: {node: '>=10'} 10206 10649 peerDependencies: 10207 10650 '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 10208 10651 react: ^16.8.0 || ^17.0.0 || ^18.0.0 10652 + peerDependenciesMeta: 10653 + '@types/react': 10654 + optional: true 10655 + 10656 + react-style-singleton@2.2.3: 10657 + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} 10658 + engines: {node: '>=10'} 10659 + peerDependencies: 10660 + '@types/react': '*' 10661 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc 10209 10662 peerDependenciesMeta: 10210 10663 '@types/react': 10211 10664 optional: true ··· 11697 12150 '@types/react': 11698 12151 optional: true 11699 12152 12153 + use-callback-ref@1.3.3: 12154 + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} 12155 + engines: {node: '>=10'} 12156 + peerDependencies: 12157 + '@types/react': '*' 12158 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc 12159 + peerDependenciesMeta: 12160 + '@types/react': 12161 + optional: true 12162 + 11700 12163 use-latest-callback@0.2.1: 11701 12164 resolution: {integrity: sha512-QWlq8Is8BGWBf883QOEQP5HWYX/kMI+JTbJ5rdtvJLmXTIh9XoHIO3PQcmQl8BU44VKxow1kbQUHa6mQSMALDQ==} 11702 12165 peerDependencies: ··· 11708 12171 peerDependencies: 11709 12172 '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0 11710 12173 react: ^16.8.0 || ^17.0.0 || ^18.0.0 12174 + peerDependenciesMeta: 12175 + '@types/react': 12176 + optional: true 12177 + 12178 + use-sidecar@1.1.3: 12179 + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} 12180 + engines: {node: '>=10'} 12181 + peerDependencies: 12182 + '@types/react': '*' 12183 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc 11711 12184 peerDependenciesMeta: 11712 12185 '@types/react': 11713 12186 optional: true ··· 15413 15886 - supports-color 15414 15887 optional: true 15415 15888 15889 + '@gorhom/bottom-sheet@5.1.6(@types/react@18.3.12)(react-native-gesture-handler@2.20.2(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native-reanimated@3.16.7(@babel/core@7.26.0)(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 15890 + dependencies: 15891 + '@gorhom/portal': 1.0.14(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 15892 + invariant: 2.2.4 15893 + react: 19.0.0 15894 + react-native: 0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10) 15895 + react-native-gesture-handler: 2.20.2(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 15896 + react-native-reanimated: 3.16.7(@babel/core@7.26.0)(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 15897 + optionalDependencies: 15898 + '@types/react': 18.3.12 15899 + 15900 + '@gorhom/portal@1.0.14(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 15901 + dependencies: 15902 + nanoid: 3.3.11 15903 + react: 19.0.0 15904 + react-native: 0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10) 15905 + 15416 15906 '@grpc/grpc-js@1.10.10': 15417 15907 dependencies: 15418 15908 '@grpc/proto-loader': 0.7.13 ··· 16189 16679 16190 16680 '@protobufjs/utf8@1.1.0': {} 16191 16681 16682 + '@radix-ui/primitive@1.1.2': {} 16683 + 16684 + '@radix-ui/react-arrow@1.1.7(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 16685 + dependencies: 16686 + '@radix-ui/react-primitive': 2.1.3(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16687 + react: 19.0.0 16688 + react-dom: 19.0.0(react@19.0.0) 16689 + optionalDependencies: 16690 + '@types/react': 18.3.12 16691 + 16692 + '@radix-ui/react-collection@1.1.7(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 16693 + dependencies: 16694 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16695 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16696 + '@radix-ui/react-primitive': 2.1.3(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16697 + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.12)(react@19.0.0) 16698 + react: 19.0.0 16699 + react-dom: 19.0.0(react@19.0.0) 16700 + optionalDependencies: 16701 + '@types/react': 18.3.12 16702 + 16703 + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.12)(react@19.0.0)': 16704 + dependencies: 16705 + react: 19.0.0 16706 + optionalDependencies: 16707 + '@types/react': 18.3.12 16708 + 16709 + '@radix-ui/react-context@1.1.2(@types/react@18.3.12)(react@19.0.0)': 16710 + dependencies: 16711 + react: 19.0.0 16712 + optionalDependencies: 16713 + '@types/react': 18.3.12 16714 + 16715 + '@radix-ui/react-direction@1.1.1(@types/react@18.3.12)(react@19.0.0)': 16716 + dependencies: 16717 + react: 19.0.0 16718 + optionalDependencies: 16719 + '@types/react': 18.3.12 16720 + 16721 + '@radix-ui/react-dismissable-layer@1.1.10(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 16722 + dependencies: 16723 + '@radix-ui/primitive': 1.1.2 16724 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16725 + '@radix-ui/react-primitive': 2.1.3(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16726 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16727 + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16728 + react: 19.0.0 16729 + react-dom: 19.0.0(react@19.0.0) 16730 + optionalDependencies: 16731 + '@types/react': 18.3.12 16732 + 16733 + '@radix-ui/react-dropdown-menu@2.1.15(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 16734 + dependencies: 16735 + '@radix-ui/primitive': 1.1.2 16736 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16737 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16738 + '@radix-ui/react-id': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16739 + '@radix-ui/react-menu': 2.1.15(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16740 + '@radix-ui/react-primitive': 2.1.3(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16741 + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.12)(react@19.0.0) 16742 + react: 19.0.0 16743 + react-dom: 19.0.0(react@19.0.0) 16744 + optionalDependencies: 16745 + '@types/react': 18.3.12 16746 + 16747 + '@radix-ui/react-focus-guards@1.1.2(@types/react@18.3.12)(react@19.0.0)': 16748 + dependencies: 16749 + react: 19.0.0 16750 + optionalDependencies: 16751 + '@types/react': 18.3.12 16752 + 16753 + '@radix-ui/react-focus-scope@1.1.7(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 16754 + dependencies: 16755 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16756 + '@radix-ui/react-primitive': 2.1.3(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16757 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16758 + react: 19.0.0 16759 + react-dom: 19.0.0(react@19.0.0) 16760 + optionalDependencies: 16761 + '@types/react': 18.3.12 16762 + 16763 + '@radix-ui/react-id@1.1.1(@types/react@18.3.12)(react@19.0.0)': 16764 + dependencies: 16765 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16766 + react: 19.0.0 16767 + optionalDependencies: 16768 + '@types/react': 18.3.12 16769 + 16770 + '@radix-ui/react-menu@2.1.15(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 16771 + dependencies: 16772 + '@radix-ui/primitive': 1.1.2 16773 + '@radix-ui/react-collection': 1.1.7(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16774 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16775 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16776 + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16777 + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16778 + '@radix-ui/react-focus-guards': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16779 + '@radix-ui/react-focus-scope': 1.1.7(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16780 + '@radix-ui/react-id': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16781 + '@radix-ui/react-popper': 1.2.7(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16782 + '@radix-ui/react-portal': 1.1.9(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16783 + '@radix-ui/react-presence': 1.1.4(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16784 + '@radix-ui/react-primitive': 2.1.3(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16785 + '@radix-ui/react-roving-focus': 1.1.10(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16786 + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.12)(react@19.0.0) 16787 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16788 + aria-hidden: 1.2.4 16789 + react: 19.0.0 16790 + react-dom: 19.0.0(react@19.0.0) 16791 + react-remove-scroll: 2.7.1(@types/react@18.3.12)(react@19.0.0) 16792 + optionalDependencies: 16793 + '@types/react': 18.3.12 16794 + 16795 + '@radix-ui/react-popper@1.2.7(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 16796 + dependencies: 16797 + '@floating-ui/react-dom': 2.1.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16798 + '@radix-ui/react-arrow': 1.1.7(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16799 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16800 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16801 + '@radix-ui/react-primitive': 2.1.3(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16802 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16803 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16804 + '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16805 + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16806 + '@radix-ui/rect': 1.1.1 16807 + react: 19.0.0 16808 + react-dom: 19.0.0(react@19.0.0) 16809 + optionalDependencies: 16810 + '@types/react': 18.3.12 16811 + 16812 + '@radix-ui/react-portal@1.1.9(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 16813 + dependencies: 16814 + '@radix-ui/react-primitive': 2.1.3(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16815 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16816 + react: 19.0.0 16817 + react-dom: 19.0.0(react@19.0.0) 16818 + optionalDependencies: 16819 + '@types/react': 18.3.12 16820 + 16821 + '@radix-ui/react-presence@1.1.4(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 16822 + dependencies: 16823 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16824 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16825 + react: 19.0.0 16826 + react-dom: 19.0.0(react@19.0.0) 16827 + optionalDependencies: 16828 + '@types/react': 18.3.12 16829 + 16830 + '@radix-ui/react-primitive@2.1.3(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 16831 + dependencies: 16832 + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.12)(react@19.0.0) 16833 + react: 19.0.0 16834 + react-dom: 19.0.0(react@19.0.0) 16835 + optionalDependencies: 16836 + '@types/react': 18.3.12 16837 + 16838 + '@radix-ui/react-roving-focus@1.1.10(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': 16839 + dependencies: 16840 + '@radix-ui/primitive': 1.1.2 16841 + '@radix-ui/react-collection': 1.1.7(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16842 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16843 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16844 + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16845 + '@radix-ui/react-id': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16846 + '@radix-ui/react-primitive': 2.1.3(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 16847 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16848 + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.12)(react@19.0.0) 16849 + react: 19.0.0 16850 + react-dom: 19.0.0(react@19.0.0) 16851 + optionalDependencies: 16852 + '@types/react': 18.3.12 16853 + 16854 + '@radix-ui/react-slot@1.2.3(@types/react@18.3.12)(react@19.0.0)': 16855 + dependencies: 16856 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.12)(react@19.0.0) 16857 + react: 19.0.0 16858 + optionalDependencies: 16859 + '@types/react': 18.3.12 16860 + 16861 + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.12)(react@19.0.0)': 16862 + dependencies: 16863 + react: 19.0.0 16864 + optionalDependencies: 16865 + '@types/react': 18.3.12 16866 + 16867 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.12)(react@19.0.0)': 16868 + dependencies: 16869 + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.12)(react@19.0.0) 16870 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16871 + react: 19.0.0 16872 + optionalDependencies: 16873 + '@types/react': 18.3.12 16874 + 16875 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.12)(react@19.0.0)': 16876 + dependencies: 16877 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16878 + react: 19.0.0 16879 + optionalDependencies: 16880 + '@types/react': 18.3.12 16881 + 16882 + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.12)(react@19.0.0)': 16883 + dependencies: 16884 + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16885 + react: 19.0.0 16886 + optionalDependencies: 16887 + '@types/react': 18.3.12 16888 + 16889 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.12)(react@19.0.0)': 16890 + dependencies: 16891 + react: 19.0.0 16892 + optionalDependencies: 16893 + '@types/react': 18.3.12 16894 + 16895 + '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.12)(react@19.0.0)': 16896 + dependencies: 16897 + '@radix-ui/rect': 1.1.1 16898 + react: 19.0.0 16899 + optionalDependencies: 16900 + '@types/react': 18.3.12 16901 + 16902 + '@radix-ui/react-use-size@1.1.1(@types/react@18.3.12)(react@19.0.0)': 16903 + dependencies: 16904 + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.12)(react@19.0.0) 16905 + react: 19.0.0 16906 + optionalDependencies: 16907 + '@types/react': 18.3.12 16908 + 16909 + '@radix-ui/rect@1.1.1': {} 16910 + 16192 16911 '@react-native-firebase/app@22.2.1(expo@53.0.11(@babel/core@7.26.0)(@expo/metro-runtime@5.0.4(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10)))(bufferutil@4.0.8)(react-native-webview@13.15.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(utf-8-validate@5.0.10))(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 16193 16912 dependencies: 16194 16913 firebase: 11.3.1 ··· 16681 17400 - bluebird 16682 17401 - supports-color 16683 17402 17403 + '@rn-primitives/dropdown-menu@1.2.0(@rn-primitives/portal@1.3.0(@types/react@18.3.12)(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)))(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 17404 + dependencies: 17405 + '@radix-ui/react-dropdown-menu': 2.1.15(@types/react@18.3.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17406 + '@rn-primitives/hooks': 1.3.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 17407 + '@rn-primitives/portal': 1.3.0(@types/react@18.3.12)(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)) 17408 + '@rn-primitives/slot': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 17409 + '@rn-primitives/types': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 17410 + '@rn-primitives/utils': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 17411 + react: 19.0.0 17412 + optionalDependencies: 17413 + react-native: 0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10) 17414 + react-native-web: 0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17415 + transitivePeerDependencies: 17416 + - '@types/react' 17417 + - '@types/react-dom' 17418 + - react-dom 17419 + 17420 + '@rn-primitives/hooks@1.3.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 17421 + dependencies: 17422 + '@rn-primitives/types': 1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 17423 + react: 19.0.0 17424 + optionalDependencies: 17425 + react-native: 0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10) 17426 + react-native-web: 0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17427 + 17428 + '@rn-primitives/portal@1.3.0(@types/react@18.3.12)(immer@10.1.1)(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0))': 17429 + dependencies: 17430 + react: 19.0.0 17431 + zustand: 5.0.5(@types/react@18.3.12)(immer@10.1.1)(react@19.0.0)(use-sync-external-store@1.2.2(react@19.0.0)) 17432 + optionalDependencies: 17433 + react-native: 0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10) 17434 + react-native-web: 0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17435 + transitivePeerDependencies: 17436 + - '@types/react' 17437 + - immer 17438 + - use-sync-external-store 17439 + 17440 + '@rn-primitives/slot@1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 17441 + dependencies: 17442 + react: 19.0.0 17443 + optionalDependencies: 17444 + react-native: 0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10) 17445 + react-native-web: 0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17446 + 17447 + '@rn-primitives/types@1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 17448 + dependencies: 17449 + react: 19.0.0 17450 + optionalDependencies: 17451 + react-native: 0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10) 17452 + react-native-web: 0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17453 + 17454 + '@rn-primitives/utils@1.2.0(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0)': 17455 + dependencies: 17456 + react: 19.0.0 17457 + optionalDependencies: 17458 + react-native: 0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10) 17459 + react-native-web: 0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 17460 + 16684 17461 '@rollup/pluginutils@5.1.4(rollup@4.40.1)': 16685 17462 dependencies: 16686 17463 '@types/estree': 1.0.7 ··· 18909 19686 abitype@1.0.6(typescript@5.6.3)(zod@3.24.4): 18910 19687 optionalDependencies: 18911 19688 typescript: 5.6.3 19689 + zod: 3.24.4 19690 + 19691 + abitype@1.0.6(typescript@5.8.3)(zod@3.24.4): 19692 + optionalDependencies: 19693 + typescript: 5.8.3 18912 19694 zod: 3.24.4 18913 19695 18914 19696 abort-controller@3.0.0: ··· 19910 20692 19911 20693 ci-info@4.2.0: {} 19912 20694 20695 + class-variance-authority@0.6.1: 20696 + dependencies: 20697 + clsx: 1.2.1 20698 + 19913 20699 clean-css@5.3.3: 19914 20700 dependencies: 19915 20701 source-map: 0.6.1 ··· 19981 20767 clone@1.0.4: {} 19982 20768 19983 20769 clone@2.1.2: {} 20770 + 20771 + clsx@1.2.1: {} 19984 20772 19985 20773 clsx@2.1.1: {} 19986 20774 ··· 23641 24429 lodash.clonedeep: 4.5.0 23642 24430 lru-cache: 6.0.0 23643 24431 24432 + lucide-react-native@0.514.0(react-native-svg@15.12.0(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0))(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 24433 + dependencies: 24434 + react: 19.0.0 24435 + react-native: 0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10) 24436 + react-native-svg: 15.12.0(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 24437 + 23644 24438 macos-alias@0.2.11: 23645 24439 dependencies: 23646 24440 nan: 2.20.0 ··· 25234 26028 transitivePeerDependencies: 25235 26029 - zod 25236 26030 26031 + ox@0.1.2(typescript@5.8.3)(zod@3.24.4): 26032 + dependencies: 26033 + '@adraffy/ens-normalize': 1.11.0 26034 + '@noble/curves': 1.8.0 26035 + '@noble/hashes': 1.7.0 26036 + '@scure/bip32': 1.5.0 26037 + '@scure/bip39': 1.4.0 26038 + abitype: 1.0.6(typescript@5.8.3)(zod@3.24.4) 26039 + eventemitter3: 5.0.1 26040 + optionalDependencies: 26041 + typescript: 5.8.3 26042 + transitivePeerDependencies: 26043 + - zod 26044 + 25237 26045 oxc-resolver@9.0.2: 25238 26046 optionalDependencies: 25239 26047 '@oxc-resolver/binding-darwin-arm64': 9.0.2 ··· 25893 26701 dependencies: 25894 26702 prop-types: 15.8.1 25895 26703 26704 + react-native-gesture-handler@2.20.2(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 26705 + dependencies: 26706 + '@egjs/hammerjs': 2.0.17 26707 + hoist-non-react-statics: 3.3.2 26708 + invariant: 2.2.4 26709 + prop-types: 15.8.1 26710 + react: 19.0.0 26711 + react-native: 0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10) 26712 + 25896 26713 react-native-gesture-handler@2.26.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 25897 26714 dependencies: 25898 26715 '@egjs/hammerjs': 2.0.17 ··· 25949 26766 - react 25950 26767 - react-native 25951 26768 26769 + react-native-reanimated@3.16.7(@babel/core@7.26.0)(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 26770 + dependencies: 26771 + '@babel/core': 7.26.0 26772 + '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.26.0) 26773 + '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.26.0) 26774 + '@babel/plugin-transform-classes': 7.27.1(@babel/core@7.26.0) 26775 + '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.26.0) 26776 + '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.26.0) 26777 + '@babel/plugin-transform-shorthand-properties': 7.27.1(@babel/core@7.26.0) 26778 + '@babel/plugin-transform-template-literals': 7.27.1(@babel/core@7.26.0) 26779 + '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.26.0) 26780 + '@babel/preset-typescript': 7.24.7(@babel/core@7.26.0) 26781 + convert-source-map: 2.0.0 26782 + invariant: 2.2.4 26783 + react: 19.0.0 26784 + react-native: 0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10) 26785 + transitivePeerDependencies: 26786 + - supports-color 26787 + 25952 26788 react-native-reanimated@3.18.0(@babel/core@7.26.0)(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 25953 26789 dependencies: 25954 26790 '@babel/core': 7.26.0 ··· 25969 26805 transitivePeerDependencies: 25970 26806 - supports-color 25971 26807 26808 + react-native-safe-area-context@4.12.0(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 26809 + dependencies: 26810 + react: 19.0.0 26811 + react-native: 0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10) 26812 + 25972 26813 react-native-safe-area-context@5.4.1(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 25973 26814 dependencies: 25974 26815 react: 19.0.0 ··· 25980 26821 react-freeze: 1.0.4(react@19.0.0) 25981 26822 react-native: 0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10) 25982 26823 react-native-is-edge-to-edge: 1.1.7(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0) 26824 + warn-once: 0.1.1 26825 + 26826 + react-native-svg@15.12.0(react-native@0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): 26827 + dependencies: 26828 + css-select: 5.1.0 26829 + css-tree: 1.1.3 26830 + react: 19.0.0 26831 + react-native: 0.76.2(@babel/core@7.26.0)(@babel/preset-env@7.27.2(@babel/core@7.26.0))(@types/react@18.3.12)(bufferutil@4.0.8)(encoding@0.1.13)(react@19.0.0)(utf-8-validate@5.0.10) 25983 26832 warn-once: 0.1.1 25984 26833 25985 26834 react-native-svg@15.12.0(react-native@0.79.3(@babel/core@7.26.0)(@types/react@18.3.12)(bufferutil@4.0.8)(react@19.0.0)(utf-8-validate@5.0.10))(react@19.0.0): ··· 26203 27052 optionalDependencies: 26204 27053 '@types/react': 18.3.12 26205 27054 27055 + react-remove-scroll-bar@2.3.8(@types/react@18.3.12)(react@19.0.0): 27056 + dependencies: 27057 + react: 19.0.0 27058 + react-style-singleton: 2.2.3(@types/react@18.3.12)(react@19.0.0) 27059 + tslib: 2.8.1 27060 + optionalDependencies: 27061 + '@types/react': 18.3.12 27062 + 26206 27063 react-remove-scroll@2.6.0(@types/react@18.3.12)(react@19.0.0): 26207 27064 dependencies: 26208 27065 react: 19.0.0 ··· 26214 27071 optionalDependencies: 26215 27072 '@types/react': 18.3.12 26216 27073 27074 + react-remove-scroll@2.7.1(@types/react@18.3.12)(react@19.0.0): 27075 + dependencies: 27076 + react: 19.0.0 27077 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.12)(react@19.0.0) 27078 + react-style-singleton: 2.2.3(@types/react@18.3.12)(react@19.0.0) 27079 + tslib: 2.8.1 27080 + use-callback-ref: 1.3.3(@types/react@18.3.12)(react@19.0.0) 27081 + use-sidecar: 1.1.3(@types/react@18.3.12)(react@19.0.0) 27082 + optionalDependencies: 27083 + '@types/react': 18.3.12 27084 + 26217 27085 react-style-singleton@2.2.1(@types/react@18.3.12)(react@19.0.0): 26218 27086 dependencies: 26219 27087 get-nonce: 1.0.1 26220 27088 invariant: 2.2.4 27089 + react: 19.0.0 27090 + tslib: 2.8.1 27091 + optionalDependencies: 27092 + '@types/react': 18.3.12 27093 + 27094 + react-style-singleton@2.2.3(@types/react@18.3.12)(react@19.0.0): 27095 + dependencies: 27096 + get-nonce: 1.0.1 26221 27097 react: 19.0.0 26222 27098 tslib: 2.8.1 26223 27099 optionalDependencies: ··· 28039 28915 optionalDependencies: 28040 28916 '@types/react': 18.3.12 28041 28917 28918 + use-callback-ref@1.3.3(@types/react@18.3.12)(react@19.0.0): 28919 + dependencies: 28920 + react: 19.0.0 28921 + tslib: 2.8.1 28922 + optionalDependencies: 28923 + '@types/react': 18.3.12 28924 + 28042 28925 use-latest-callback@0.2.1(react@19.0.0): 28043 28926 dependencies: 28044 28927 react: 19.0.0 28045 28928 28046 28929 use-sidecar@1.1.2(@types/react@18.3.12)(react@19.0.0): 28930 + dependencies: 28931 + detect-node-es: 1.1.0 28932 + react: 19.0.0 28933 + tslib: 2.8.1 28934 + optionalDependencies: 28935 + '@types/react': 18.3.12 28936 + 28937 + use-sidecar@1.1.3(@types/react@18.3.12)(react@19.0.0): 28047 28938 dependencies: 28048 28939 detect-node-es: 1.1.0 28049 28940 react: 19.0.0 ··· 28146 29037 ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) 28147 29038 optionalDependencies: 28148 29039 typescript: 5.6.3 29040 + transitivePeerDependencies: 29041 + - bufferutil 29042 + - utf-8-validate 29043 + - zod 29044 + 29045 + viem@2.21.44(bufferutil@4.0.8)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.24.4): 29046 + dependencies: 29047 + '@noble/curves': 1.6.0 29048 + '@noble/hashes': 1.5.0 29049 + '@scure/bip32': 1.5.0 29050 + '@scure/bip39': 1.4.0 29051 + abitype: 1.0.6(typescript@5.8.3)(zod@3.24.4) 29052 + isows: 1.0.6(ws@8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10)) 29053 + ox: 0.1.2(typescript@5.8.3)(zod@3.24.4) 29054 + webauthn-p256: 0.0.10 29055 + ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@5.0.10) 29056 + optionalDependencies: 29057 + typescript: 5.8.3 28149 29058 transitivePeerDependencies: 28150 29059 - bufferutil 28151 29060 - utf-8-validate