chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
+123
View File
@@ -0,0 +1,123 @@
// Prevent pulling in all of expo-modules-core on web
import { LegacyEventEmitter } from 'expo-modules-core';
import React, { useEffect, useState, useRef, useMemo } from 'react';
import { Animated, StyleSheet, Text, View } from 'react-native';
import DevLoadingViewNativeModule from './DevLoadingViewNativeModule';
import { getInitialSafeArea } from './getInitialSafeArea';
export default function DevLoadingView() {
const [message, setMessage] = useState('Refreshing...');
const [isDevLoading, setIsDevLoading] = useState(false);
const [isAnimating, setIsAnimating] = useState(false);
const translateY = useRef(new Animated.Value(0)).current;
const emitter = useMemo<LegacyEventEmitter>(() => {
try {
return new LegacyEventEmitter(DevLoadingViewNativeModule);
} catch (error: any) {
throw new Error(
'Failed to instantiate native emitter in `DevLoadingView` because the native module `DevLoadingView` is undefined: ' +
error.message
);
}
}, []);
useEffect(() => {
if (!emitter) return;
function handleShowMessage(event: { message: string }) {
setMessage(event.message);
// TODO: if we show the refreshing banner and don't get a hide message
// for 3 seconds, warn the user that it's taking a while and suggest
// they reload
translateY.setValue(0);
setIsDevLoading(true);
}
function handleHide() {
// TODO: if we showed the 'refreshing' banner less than 250ms ago, delay
// switching to the 'finished' banner
setIsAnimating(true);
setIsDevLoading(false);
Animated.timing(translateY, {
toValue: 150,
delay: 1000,
duration: 350,
useNativeDriver: true,
}).start(({ finished }) => {
if (finished) {
setIsAnimating(false);
translateY.setValue(0);
}
});
}
const showMessageSubscription = emitter.addListener(
'devLoadingView:showMessage',
handleShowMessage
);
const hideSubscription = emitter.addListener('devLoadingView:hide', handleHide);
return function cleanup() {
showMessageSubscription.remove();
hideSubscription.remove();
};
}, [translateY, emitter]);
if (!isDevLoading && !isAnimating) {
return null;
}
return (
<Animated.View style={[styles.animatedContainer, { transform: [{ translateY }] }]}>
<View style={styles.banner}>
<View style={styles.contentContainer}>
<View style={{ flexDirection: 'row' }}>
<Text style={styles.text}>{message}</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.subtitle}>
{isDevLoading ? 'Using Fast Refresh' : "Don't see your changes? Reload the app"}
</Text>
</View>
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
animatedContainer: {
position: 'absolute',
pointerEvents: 'none',
bottom: 0,
left: 0,
right: 0,
zIndex: 42, // arbitrary
},
banner: {
flex: 1,
overflow: 'visible',
backgroundColor: 'rgba(0,0,0,0.75)',
paddingBottom: getInitialSafeArea().bottom,
},
contentContainer: {
flex: 1,
paddingTop: 10,
paddingBottom: 5,
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
},
text: {
color: '#fff',
fontSize: 15,
},
subtitle: {
color: 'rgba(255,255,255,0.8)',
},
});
+135
View File
@@ -0,0 +1,135 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { NativeEventEmitter } from 'react-native';
const MIN_DURATION = 400;
const ANIMATION_DURATION = 150;
const emitter = new NativeEventEmitter({
addListener() {},
removeListeners() {},
});
export default function DevLoadingView() {
const show = useFastRefresh();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const toast = useMemo(
() => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width={24} height={24}>
<path
fill="#ECEDEE"
d="M36.764 1.716a1.477 1.477 0 0 0-2.325-.268L11.721 24.609c-1.464 1.493-.438 4.064 1.623 4.064h4.484a1 1 0 0 1 .889 1.46l-7.54 14.591a1.588 1.588 0 0 0 .059 1.56 1.477 1.477 0 0 0 2.325.268l22.718-23.161c1.464-1.493.438-4.064-1.623-4.064H28.53l8.295-16.051a1.588 1.588 0 0 0-.06-1.56Z"
/>
</svg>
),
[]
);
const style = useMemo(
() => (
<style
dangerouslySetInnerHTML={{
__html: `
.__expo_fast_refresh {
position: fixed;
pointer-events: none;
bottom: 8px;
left: 8px;
z-index: 9999;
display: flex;
background-color: #1B1D1E;
border: 1px solid #4D5155;
padding: 8px;
border-radius: 8px;
transition: all ${ANIMATION_DURATION}ms;
opacity: 0;
filter: blur(4px);
transform: translateY(20%);
}
.__expo_fast_refresh_show { opacity: 1; filter: blur(0); transform: scale(1); }
`,
}}
/>
),
[]
);
const [isAnimating, setIsAnimating] = useState(false);
const [animationClass, setAnimationClass] = useState('');
const refreshIndicator = useMemo(
() => (
<>
{style}
<div className={'__expo_fast_refresh ' + animationClass}>{toast}</div>
</>
),
[animationClass, style, toast]
);
useEffect(() => {
timer.current && clearTimeout(timer.current);
if (show) {
setAnimationClass('__expo_fast_refresh_show');
} else {
setIsAnimating(true);
setAnimationClass('');
timer.current = setTimeout(() => {
setIsAnimating(false);
}, MIN_DURATION - ANIMATION_DURATION);
}
return () => {
timer.current && clearTimeout(timer.current);
};
}, [show]);
if (!isAnimating && !show) {
return null;
}
return <>{refreshIndicator}</>;
}
function useFastRefresh() {
const [isShown, setShown] = useState(false);
const duration = useRef<number | null>(null);
const timeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
function handleShowMessage() {
setShown(true);
duration.current = Date.now();
}
function handleHide() {
// Bail out if the timeout is already set
if (timeout.current) {
return;
}
const timeVisible = duration.current ? Date.now() - duration.current : 0;
const min = Math.max(0, MIN_DURATION - timeVisible);
timeout.current = setTimeout(() => {
timeout.current = null;
setShown(false);
}, min);
}
const show = emitter.addListener('devLoadingView:showMessage', handleShowMessage);
const hide = emitter.addListener('devLoadingView:hide', handleHide);
return () => {
if (timeout.current) {
clearTimeout(timeout.current);
timeout.current = null;
}
show.remove();
hide.remove();
};
}, [emitter]);
return isShown;
}
@@ -0,0 +1,3 @@
import { NativeModules } from 'react-native';
export default NativeModules.DevLoadingView;
@@ -0,0 +1,7 @@
export default Object.freeze({
name: 'DevLoadingView',
startObserving() {},
stopObserving() {},
addListener() {},
removeListeners() {},
});
+53
View File
@@ -0,0 +1,53 @@
import { requireNativeModule } from 'expo-modules-core';
type ExpoGoModule = {
expoVersion: string;
projectConfig: ExpoGoProjectConfig;
};
type ExpoGoProjectConfig = {
mainModuleName?: string;
debuggerHost?: string;
logUrl?: string;
developer?: {
tool?: string;
[key: string]: any;
};
packagerOpts?: ExpoGoPackagerOpts;
};
export type ExpoGoPackagerOpts = {
hostType?: string;
dev?: boolean;
strict?: boolean;
minify?: boolean;
urlType?: string;
urlRandomness?: string;
lanType?: string;
[key: string]: any;
};
// ExpoGo module is available only when the app is run in Expo Go,
// otherwise we use `null` instead of throwing an error.
const NativeExpoGoModule = ((): ExpoGoModule | null => {
try {
return requireNativeModule('ExpoGo');
} catch {
return null;
}
})();
/**
* Returns a boolean value whether the app is running in Expo Go.
*/
export function isRunningInExpoGo(): boolean {
return NativeExpoGoModule != null;
}
/**
* @hidden
* Returns an Expo Go project config from the manifest or `null` if the app is not running in Expo Go.
*/
export function getExpoGoProjectConfig(): ExpoGoProjectConfig | null {
return NativeExpoGoModule?.projectConfig ?? null;
}
+7
View File
@@ -0,0 +1,7 @@
export function isRunningInExpoGo() {
return false;
}
export function getExpoGoProjectConfig() {
return null;
}
@@ -0,0 +1,16 @@
import { TurboModuleRegistry } from 'react-native';
const DEFAULT_SAFE_AREA = { top: 0, bottom: 0, left: 0, right: 0 };
/**
* Get the best estimate safe area before native modules have fully loaded.
* This is a hack to get the safe area insets without explicitly depending on react-native-safe-area-context.
*/
export function getInitialSafeArea(): { top: number; bottom: number; left: number; right: number } {
const RNCSafeAreaContext = TurboModuleRegistry.get('RNCSafeAreaContext');
// @ts-ignore: we're not using the spec so the return type of getConstants() is {}
const initialWindowMetrics = RNCSafeAreaContext?.getConstants()?.initialWindowMetrics;
return initialWindowMetrics?.insets ?? DEFAULT_SAFE_AREA;
}
@@ -0,0 +1,8 @@
export function getInitialSafeArea(): { top: number; bottom: number; left: number; right: number } {
return {
top: 0,
bottom: 0,
left: 0,
right: 0,
};
}