Initial commit: code as received (Create/Anything export)
Insole-production time tracker exported from the Create/Anything AI platform. Baseline snapshot before any reverse-engineering or cleanup. - apps/mobile: Expo Router app (iOS/Android/web), the only workspace - publisher/: standalone OpenNext/AWS deploy tooling for the web side - Backend (/api/tasks, /api/logs + DB) lives remotely, not in this repo
This commit is contained in:
424
apps/mobile/polyfills/native/google-mobile-ads.native.tsx
Normal file
424
apps/mobile/polyfills/native/google-mobile-ads.native.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import type React from "react";
|
||||
import { Text, View, type ViewStyle } from "react-native";
|
||||
|
||||
// Stub for react-native-google-mobile-ads on web.
|
||||
// Ads are native-only; these render visual placeholders so users can preview
|
||||
// their layouts in Expo Go without the native module.
|
||||
|
||||
export const BannerAdSize = {
|
||||
BANNER: "BANNER",
|
||||
FULL_BANNER: "FULL_BANNER",
|
||||
LARGE_BANNER: "LARGE_BANNER",
|
||||
LEADERBOARD: "LEADERBOARD",
|
||||
MEDIUM_RECTANGLE: "MEDIUM_RECTANGLE",
|
||||
ADAPTIVE_BANNER: "ADAPTIVE_BANNER",
|
||||
ANCHORED_ADAPTIVE_BANNER: "ANCHORED_ADAPTIVE_BANNER",
|
||||
INLINE_ADAPTIVE_BANNER: "INLINE_ADAPTIVE_BANNER",
|
||||
WIDE_SKYSCRAPER: "WIDE_SKYSCRAPER",
|
||||
};
|
||||
|
||||
export const AdEventType = {
|
||||
LOADED: "loaded",
|
||||
ERROR: "error",
|
||||
OPENED: "opened",
|
||||
CLICKED: "clicked",
|
||||
CLOSED: "closed",
|
||||
};
|
||||
|
||||
export const RewardedAdEventType = {
|
||||
LOADED: "loaded",
|
||||
EARNED_REWARD: "earned_reward",
|
||||
};
|
||||
|
||||
export const AdsConsentStatus = {
|
||||
UNKNOWN: 0,
|
||||
REQUIRED: 1,
|
||||
NOT_REQUIRED: 2,
|
||||
OBTAINED: 3,
|
||||
};
|
||||
|
||||
export const AdsConsentDebugGeography = {
|
||||
DISABLED: 0,
|
||||
EEA: 1,
|
||||
NOT_EEA: 2,
|
||||
};
|
||||
|
||||
export const TestIds = {
|
||||
BANNER: "ca-app-pub-3940256099942544/6300978111",
|
||||
GAM_BANNER: "ca-app-pub-3940256099942544/6300978111",
|
||||
INTERSTITIAL: "ca-app-pub-3940256099942544/1033173712",
|
||||
GAM_INTERSTITIAL: "ca-app-pub-3940256099942544/1033173712",
|
||||
REWARDED: "ca-app-pub-3940256099942544/5224354917",
|
||||
REWARDED_INTERSTITIAL: "ca-app-pub-3940256099942544/5354046379",
|
||||
APP_OPEN: "ca-app-pub-3940256099942544/3419835294",
|
||||
NATIVE: "ca-app-pub-3940256099942544/2247696110",
|
||||
NATIVE_VIDEO: "ca-app-pub-3940256099942544/1044960115",
|
||||
};
|
||||
|
||||
const PLACEHOLDER_BG = "#f5f5f5";
|
||||
const PLACEHOLDER_BORDER = "#e0e0e0";
|
||||
const PLACEHOLDER_TEXT = "#999999";
|
||||
const AD_LABEL_BG = "#fbbc04";
|
||||
const AD_LABEL_TEXT = "#1a1a1a";
|
||||
|
||||
const AdLabel = () => (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: AD_LABEL_BG,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: "700",
|
||||
color: AD_LABEL_TEXT,
|
||||
lineHeight: 11,
|
||||
}}
|
||||
>
|
||||
Ad
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const getBannerStyle = (size: string | undefined): ViewStyle => {
|
||||
switch (size) {
|
||||
case "FULL_BANNER":
|
||||
return { width: 468, height: 60 };
|
||||
case "LARGE_BANNER":
|
||||
return { width: 320, height: 100 };
|
||||
case "LEADERBOARD":
|
||||
return { width: 728, height: 90 };
|
||||
case "MEDIUM_RECTANGLE":
|
||||
return { width: 300, height: 250 };
|
||||
case "WIDE_SKYSCRAPER":
|
||||
return { width: 160, height: 600 };
|
||||
case "ADAPTIVE_BANNER":
|
||||
case "ANCHORED_ADAPTIVE_BANNER":
|
||||
return { width: "100%", height: 50 };
|
||||
case "INLINE_ADAPTIVE_BANNER":
|
||||
return { width: "100%", height: 100 };
|
||||
default:
|
||||
return { width: 320, height: 50 };
|
||||
}
|
||||
};
|
||||
|
||||
type BannerAdProps = {
|
||||
size?: string;
|
||||
unitId?: string;
|
||||
onAdLoaded?: () => void;
|
||||
onAdFailedToLoad?: (error: unknown) => void;
|
||||
onAdOpened?: () => void;
|
||||
onAdClosed?: () => void;
|
||||
};
|
||||
|
||||
const BannerPlaceholder = ({
|
||||
size,
|
||||
label,
|
||||
}: { size?: string; label: string }) => {
|
||||
const dims = getBannerStyle(size);
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
...dims,
|
||||
backgroundColor: PLACEHOLDER_BG,
|
||||
borderWidth: 1,
|
||||
borderColor: PLACEHOLDER_BORDER,
|
||||
borderRadius: 4,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<AdLabel />
|
||||
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const BannerAd = ({ size }: BannerAdProps) => (
|
||||
<BannerPlaceholder size={size} label="Banner Ad" />
|
||||
);
|
||||
|
||||
export const GAMBannerAd = ({ size }: BannerAdProps) => (
|
||||
<BannerPlaceholder size={size} label="Ad Manager Banner" />
|
||||
);
|
||||
|
||||
type NativeAdViewProps = {
|
||||
children?: React.ReactNode;
|
||||
nativeAd?: unknown;
|
||||
style?: ViewStyle | ViewStyle[];
|
||||
};
|
||||
|
||||
const DefaultNativeAdContent = () => (
|
||||
<View>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", marginBottom: 10 }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: PLACEHOLDER_BORDER,
|
||||
marginRight: 10,
|
||||
}}
|
||||
/>
|
||||
<View style={{ flex: 1 }}>
|
||||
<View
|
||||
style={{
|
||||
height: 12,
|
||||
backgroundColor: PLACEHOLDER_BORDER,
|
||||
borderRadius: 4,
|
||||
marginBottom: 6,
|
||||
width: "70%",
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: 10,
|
||||
backgroundColor: "#ececec",
|
||||
borderRadius: 4,
|
||||
width: "40%",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
height: 140,
|
||||
backgroundColor: PLACEHOLDER_BORDER,
|
||||
borderRadius: 4,
|
||||
marginBottom: 10,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>
|
||||
Native Ad Media
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
height: 10,
|
||||
backgroundColor: "#ececec",
|
||||
borderRadius: 4,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: 10,
|
||||
backgroundColor: "#ececec",
|
||||
borderRadius: 4,
|
||||
width: "80%",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "#1a73e8",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#fff", fontSize: 12, fontWeight: "600" }}>
|
||||
Install
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
export const NativeAdView = ({ children, style }: NativeAdViewProps) => (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: PLACEHOLDER_BG,
|
||||
borderWidth: 1,
|
||||
borderColor: PLACEHOLDER_BORDER,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
position: "relative",
|
||||
},
|
||||
style as ViewStyle,
|
||||
]}
|
||||
>
|
||||
<View style={{ position: "absolute", top: 8, right: 8, zIndex: 1 }}>
|
||||
<AdLabel />
|
||||
</View>
|
||||
{children ?? <DefaultNativeAdContent />}
|
||||
</View>
|
||||
);
|
||||
|
||||
export const NativeAsset = ({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
assetType?: string;
|
||||
style?: ViewStyle | ViewStyle[];
|
||||
}) => <View style={style as ViewStyle}>{children}</View>;
|
||||
|
||||
export const NativeMediaView = ({
|
||||
style,
|
||||
}: { style?: ViewStyle | ViewStyle[] }) => (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
height: 180,
|
||||
backgroundColor: PLACEHOLDER_BORDER,
|
||||
borderRadius: 4,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
style as ViewStyle,
|
||||
]}
|
||||
>
|
||||
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>
|
||||
Ad Media (native only)
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
export const NativeAd = {
|
||||
createForAdRequest: async (_unitId?: string, _requestOptions?: unknown) => ({
|
||||
headline: "Sample Ad Headline",
|
||||
body: "Native ads only render on a real device.",
|
||||
advertiser: "Sample Advertiser",
|
||||
callToAction: "Install",
|
||||
icon: null,
|
||||
images: [],
|
||||
starRating: null,
|
||||
store: null,
|
||||
price: null,
|
||||
addAdEventListener: () => () => {},
|
||||
removeAllListeners: () => {},
|
||||
destroy: () => {},
|
||||
}),
|
||||
};
|
||||
|
||||
const createFullScreenAdStub = () => ({
|
||||
loaded: false,
|
||||
load: () => {},
|
||||
show: () => Promise.resolve(),
|
||||
addAdEventListener: () => () => {},
|
||||
addAdEventsListener: () => () => {},
|
||||
removeAllListeners: () => {},
|
||||
});
|
||||
|
||||
export const InterstitialAd = {
|
||||
createForAdRequest: () => createFullScreenAdStub(),
|
||||
};
|
||||
|
||||
export const RewardedAd = {
|
||||
createForAdRequest: () => createFullScreenAdStub(),
|
||||
};
|
||||
|
||||
export const RewardedInterstitialAd = {
|
||||
createForAdRequest: () => createFullScreenAdStub(),
|
||||
};
|
||||
|
||||
export const AppOpenAd = {
|
||||
createForAdRequest: () => createFullScreenAdStub(),
|
||||
};
|
||||
|
||||
export const GAMInterstitialAd = {
|
||||
createForAdRequest: () => createFullScreenAdStub(),
|
||||
};
|
||||
|
||||
export const GAMRewardedAd = {
|
||||
createForAdRequest: () => createFullScreenAdStub(),
|
||||
};
|
||||
|
||||
export const GAMRewardedInterstitialAd = {
|
||||
createForAdRequest: () => createFullScreenAdStub(),
|
||||
};
|
||||
|
||||
const baseHookResult = {
|
||||
isLoaded: false,
|
||||
isOpened: false,
|
||||
isClicked: false,
|
||||
isClosed: false,
|
||||
error: null as unknown,
|
||||
load: () => {},
|
||||
show: () => {},
|
||||
};
|
||||
|
||||
export const useInterstitialAd = () => ({ ...baseHookResult });
|
||||
export const useAppOpenAd = () => ({ ...baseHookResult });
|
||||
export const useRewardedAd = () => ({
|
||||
...baseHookResult,
|
||||
isEarnedReward: false,
|
||||
reward: null,
|
||||
});
|
||||
export const useRewardedInterstitialAd = () => ({
|
||||
...baseHookResult,
|
||||
isEarnedReward: false,
|
||||
reward: null,
|
||||
});
|
||||
|
||||
export const AdsConsent = {
|
||||
requestInfoUpdate: async () => ({
|
||||
status: AdsConsentStatus.NOT_REQUIRED,
|
||||
isConsentFormAvailable: false,
|
||||
}),
|
||||
showForm: async () => ({ status: AdsConsentStatus.OBTAINED }),
|
||||
loadAndShowConsentFormIfRequired: async () => ({
|
||||
status: AdsConsentStatus.NOT_REQUIRED,
|
||||
}),
|
||||
gatherConsent: async () => ({ status: AdsConsentStatus.NOT_REQUIRED }),
|
||||
reset: () => {},
|
||||
getConsentInfo: async () => ({
|
||||
status: AdsConsentStatus.NOT_REQUIRED,
|
||||
canRequestAds: true,
|
||||
isConsentFormAvailable: false,
|
||||
privacyOptionsRequirementStatus: "NOT_REQUIRED",
|
||||
}),
|
||||
getUserChoices: async () => ({}),
|
||||
getTCString: async () => "",
|
||||
getGdprApplies: async () => false,
|
||||
getPurposeConsents: async () => "",
|
||||
getPurposeLegitimateInterests: async () => "",
|
||||
};
|
||||
|
||||
const mobileAdsInstance = {
|
||||
initialize: async () => [],
|
||||
setRequestConfiguration: async () => {},
|
||||
openAdInspector: async () => {},
|
||||
openDebugMenu: () => {},
|
||||
setAppMuted: () => {},
|
||||
setAppVolume: () => {},
|
||||
};
|
||||
|
||||
const mobileAds = () => mobileAdsInstance;
|
||||
|
||||
const defaultExport = Object.assign(mobileAds, {
|
||||
BannerAd,
|
||||
GAMBannerAd,
|
||||
BannerAdSize,
|
||||
InterstitialAd,
|
||||
RewardedAd,
|
||||
RewardedInterstitialAd,
|
||||
AppOpenAd,
|
||||
GAMInterstitialAd,
|
||||
GAMRewardedAd,
|
||||
GAMRewardedInterstitialAd,
|
||||
NativeAd,
|
||||
NativeAdView,
|
||||
NativeAsset,
|
||||
NativeMediaView,
|
||||
AdEventType,
|
||||
RewardedAdEventType,
|
||||
AdsConsent,
|
||||
AdsConsentStatus,
|
||||
AdsConsentDebugGeography,
|
||||
TestIds,
|
||||
});
|
||||
|
||||
export { mobileAds };
|
||||
export default defaultExport;
|
||||
179
apps/mobile/polyfills/native/react-native-purchases.native.tsx
Normal file
179
apps/mobile/polyfills/native/react-native-purchases.native.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
// Expo Go-safe stub for react-native-purchases.
|
||||
//
|
||||
// The real package's index pulls in @revenuecat/purchases-js-hybrid-mappings
|
||||
// (a ~15k-line Svelte UMD bundle of browser-only code) which throws on
|
||||
// module evaluation under Hermes in Expo Go preview. Even importing it from
|
||||
// a hook that's never called crashes _layout.tsx, which makes expo-router
|
||||
// silently swallow the throw and warn "Route \"./_layout.tsx\" is missing
|
||||
// the required default export" — leaving the app stuck on a black/splash
|
||||
// screen forever.
|
||||
//
|
||||
// This polyfill is wired up in metro.config.js for native platforms only
|
||||
// when EXPO_PUBLIC_CREATE_ENV !== 'PRODUCTION'. Production EAS builds keep
|
||||
// the real SDK, so paid users hit RevenueCat as normal.
|
||||
|
||||
const noopAsync = async () => undefined;
|
||||
|
||||
const LOG_LEVEL = {
|
||||
VERBOSE: "VERBOSE",
|
||||
DEBUG: "DEBUG",
|
||||
INFO: "INFO",
|
||||
WARN: "WARN",
|
||||
ERROR: "ERROR",
|
||||
SILENT: "SILENT",
|
||||
};
|
||||
|
||||
const PRODUCT_CATEGORY = {
|
||||
SUBSCRIPTION: "SUBSCRIPTION",
|
||||
NON_SUBSCRIPTION: "NON_SUBSCRIPTION",
|
||||
UNKNOWN: "UNKNOWN",
|
||||
};
|
||||
|
||||
const PURCHASE_TYPE = {
|
||||
INAPP: "inapp",
|
||||
SUBS: "subs",
|
||||
};
|
||||
|
||||
const PURCHASES_ARE_COMPLETED_BY_TYPE = {
|
||||
REVENUECAT: "REVENUECAT",
|
||||
MY_APP: "MY_APP",
|
||||
};
|
||||
|
||||
const REFUND_REQUEST_STATUS = {
|
||||
SUCCESS: "SUCCESS",
|
||||
USER_CANCELLED: "USER_CANCELLED",
|
||||
ERROR: "ERROR",
|
||||
};
|
||||
|
||||
const BILLING_FEATURE = {
|
||||
SUBSCRIPTIONS: "SUBSCRIPTIONS",
|
||||
SUBSCRIPTIONS_UPDATE: "SUBSCRIPTIONS_UPDATE",
|
||||
IN_APP_MESSAGING: "IN_APP_MESSAGING",
|
||||
PRICE_CHANGE_CONFIRMATION: "PRICE_CHANGE_CONFIRMATION",
|
||||
};
|
||||
|
||||
const STOREKIT_VERSION = {
|
||||
DEFAULT: "DEFAULT",
|
||||
STOREKIT_1: "STOREKIT_1",
|
||||
STOREKIT_2: "STOREKIT_2",
|
||||
};
|
||||
|
||||
const Purchases = {
|
||||
configure: noopAsync,
|
||||
setLogLevel: () => {},
|
||||
setLogHandler: () => {},
|
||||
addCustomerInfoUpdateListener: () => () => {},
|
||||
removeCustomerInfoUpdateListener: () => {},
|
||||
getOfferings: async () => ({ current: null, all: {} }),
|
||||
getProducts: async () => [],
|
||||
getCustomerInfo: async () => ({
|
||||
entitlements: { active: {}, all: {} },
|
||||
activeSubscriptions: [],
|
||||
allPurchasedProductIdentifiers: [],
|
||||
latestExpirationDate: null,
|
||||
firstSeen: new Date().toISOString(),
|
||||
originalAppUserId: "expo-go-preview",
|
||||
requestDate: new Date().toISOString(),
|
||||
allExpirationDates: {},
|
||||
allPurchaseDates: {},
|
||||
originalApplicationVersion: null,
|
||||
originalPurchaseDate: null,
|
||||
managementURL: null,
|
||||
nonSubscriptionTransactions: [],
|
||||
}),
|
||||
purchasePackage: async () => {
|
||||
const error: Error & { userCancelled?: boolean } = new Error(
|
||||
"Purchases not available in Expo Go preview. Build a development build or run in TestFlight to test purchases.",
|
||||
);
|
||||
error.userCancelled = true;
|
||||
throw error;
|
||||
},
|
||||
purchaseProduct: async () => {
|
||||
const error: Error & { userCancelled?: boolean } = new Error(
|
||||
"Purchases not available in Expo Go preview.",
|
||||
);
|
||||
error.userCancelled = true;
|
||||
throw error;
|
||||
},
|
||||
restorePurchases: async () => ({
|
||||
entitlements: { active: {}, all: {} },
|
||||
activeSubscriptions: [],
|
||||
allPurchasedProductIdentifiers: [],
|
||||
latestExpirationDate: null,
|
||||
firstSeen: new Date().toISOString(),
|
||||
originalAppUserId: "expo-go-preview",
|
||||
requestDate: new Date().toISOString(),
|
||||
allExpirationDates: {},
|
||||
allPurchaseDates: {},
|
||||
originalApplicationVersion: null,
|
||||
originalPurchaseDate: null,
|
||||
managementURL: null,
|
||||
nonSubscriptionTransactions: [],
|
||||
}),
|
||||
logIn: async (appUserID: string) => ({
|
||||
customerInfo: {
|
||||
entitlements: { active: {}, all: {} },
|
||||
activeSubscriptions: [],
|
||||
originalAppUserId: appUserID,
|
||||
},
|
||||
created: false,
|
||||
}),
|
||||
logOut: async () => ({
|
||||
entitlements: { active: {}, all: {} },
|
||||
activeSubscriptions: [],
|
||||
originalAppUserId: "expo-go-preview",
|
||||
}),
|
||||
setAttributes: noopAsync,
|
||||
setEmail: noopAsync,
|
||||
setDisplayName: noopAsync,
|
||||
setPhoneNumber: noopAsync,
|
||||
setPushToken: noopAsync,
|
||||
setAdjustID: noopAsync,
|
||||
setAppsflyerID: noopAsync,
|
||||
setFBAnonymousID: noopAsync,
|
||||
setMparticleID: noopAsync,
|
||||
setOnesignalID: noopAsync,
|
||||
setAirshipChannelID: noopAsync,
|
||||
setMediaSource: noopAsync,
|
||||
setCampaign: noopAsync,
|
||||
setAdGroup: noopAsync,
|
||||
setAd: noopAsync,
|
||||
setKeyword: noopAsync,
|
||||
setCreative: noopAsync,
|
||||
collectDeviceIdentifiers: () => {},
|
||||
syncPurchases: noopAsync,
|
||||
syncAttributesAndOfferingsIfNeeded: async () => ({ current: null, all: {} }),
|
||||
enableAdServicesAttributionTokenCollection: () => {},
|
||||
isAnonymous: async () => true,
|
||||
checkTrialOrIntroductoryPriceEligibility: async () => ({}),
|
||||
invalidateCustomerInfoCache: () => {},
|
||||
presentCodeRedemptionSheet: () => {},
|
||||
beginRefundRequestForActiveEntitlement: async () => REFUND_REQUEST_STATUS.ERROR,
|
||||
beginRefundRequestForEntitlement: async () => REFUND_REQUEST_STATUS.ERROR,
|
||||
beginRefundRequestForProduct: async () => REFUND_REQUEST_STATUS.ERROR,
|
||||
showInAppMessages: noopAsync,
|
||||
getPromotionalOffer: async () => null,
|
||||
purchasePromotionalOffer: async () => {
|
||||
const error: Error & { userCancelled?: boolean } = new Error(
|
||||
"Purchases not available in Expo Go preview.",
|
||||
);
|
||||
error.userCancelled = true;
|
||||
throw error;
|
||||
},
|
||||
canMakePayments: async () => false,
|
||||
getAppUserID: async () => "expo-go-preview",
|
||||
close: () => {},
|
||||
configureInUITestMode: () => {},
|
||||
};
|
||||
|
||||
export {
|
||||
LOG_LEVEL,
|
||||
PRODUCT_CATEGORY,
|
||||
PURCHASE_TYPE,
|
||||
PURCHASES_ARE_COMPLETED_BY_TYPE,
|
||||
REFUND_REQUEST_STATUS,
|
||||
BILLING_FEATURE,
|
||||
STOREKIT_VERSION,
|
||||
};
|
||||
|
||||
export default Purchases;
|
||||
16
apps/mobile/polyfills/native/textinput.native.tsx
Normal file
16
apps/mobile/polyfills/native/textinput.native.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { TextInput as RNTextInput, type TextInputProps } from 'react-native';
|
||||
|
||||
const TextInput = React.forwardRef<RNTextInput, TextInputProps>((props, ref) => {
|
||||
return (
|
||||
<RNTextInput
|
||||
ref={ref}
|
||||
placeholderTextColor={props.placeholderTextColor || 'black'}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TextInput.displayName = 'TextInput';
|
||||
|
||||
export default TextInput;
|
||||
1
apps/mobile/polyfills/shared/empty-component.tsx
Normal file
1
apps/mobile/polyfills/shared/empty-component.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export default () => null;
|
||||
99
apps/mobile/polyfills/shared/expo-image.tsx
Normal file
99
apps/mobile/polyfills/shared/expo-image.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { ImageProps } from 'expo-image';
|
||||
import * as ExpoImage from 'expo-image';
|
||||
import { Buffer } from 'buffer';
|
||||
import React, { forwardRef, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
function buildGridPlaceholder(w: number, h: number): string {
|
||||
const size = Math.max(w, h);
|
||||
const svg = `
|
||||
<svg width="${size}" height="${size}" viewBox="0 0 895 895" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="895" height="895" rx="19" fill="#E9E7E7"/>
|
||||
<g stroke="#C0C0C0" stroke-width="1.00975">
|
||||
<line x1="447.505" y1="-23" x2="447.505" y2="901"/>
|
||||
<line x1="889.335" y1="447.505" x2="5.66443" y2="447.505"/>
|
||||
<line x1="889.335" y1="278.068" x2="5.66443" y2="278.068"/>
|
||||
<line x1="889.335" y1="57.1505" x2="5.66443" y2="57.1504"/>
|
||||
<line x1="61.8051" y1="883.671" x2="61.8051" y2="0.000061"/>
|
||||
<line x1="282.495" y1="907" x2="282.495" y2="-30"/>
|
||||
<line x1="611.495" y1="907" x2="611.495" y2="-30"/>
|
||||
<line x1="832.185" y1="883.671" x2="832.185" y2="0.000061"/>
|
||||
<line x1="889.335" y1="827.53" x2="5.66443" y2="827.53"/>
|
||||
<line x1="889.335" y1="606.613" x2="5.66443" y2="606.612"/>
|
||||
<line x1="4.3568" y1="4.6428" x2="889.357" y2="888.643"/>
|
||||
<line x1="-0.3568" y1="894.643" x2="894.643" y2="0.642772"/>
|
||||
<circle cx="447.5" cy="441.5" r="163.995"/>
|
||||
<circle cx="447.911" cy="447.911" r="237.407"/>
|
||||
<circle cx="448" cy="442" r="384.495"/>
|
||||
</g>
|
||||
</svg>`;
|
||||
const b64 = Buffer.from(svg).toString('base64');
|
||||
return `data:image/svg+xml;base64,${b64}`;
|
||||
}
|
||||
|
||||
type Src = ImageProps['source'];
|
||||
function computeSourceKey(src: Src): string {
|
||||
if (Array.isArray(src)) return src.map(computeSourceKey).join('|');
|
||||
if (typeof src === 'number') return String(src); // require('./img.png')
|
||||
if (typeof src === 'string') return src; // remote on web
|
||||
if (src && typeof src === 'object' && 'uri' in src) return src.uri ?? '';
|
||||
return '';
|
||||
}
|
||||
|
||||
const WrappedImage = forwardRef<ExpoImage.Image, ImageProps>(function WrappedImage(props, ref) {
|
||||
const [fallbackSource, setFallbackSource] = useState<Src | null>(null);
|
||||
const source = props.source;
|
||||
const onError = props.onError;
|
||||
const style = props.style;
|
||||
const currentKey = computeSourceKey(props.source);
|
||||
const prevKeyRef = useRef(currentKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (prevKeyRef.current !== currentKey) {
|
||||
// parent really pointed to a different image: clear any old fallback
|
||||
setFallbackSource(null);
|
||||
prevKeyRef.current = currentKey;
|
||||
}
|
||||
}, [currentKey]);
|
||||
const handleError: ImageProps['onError'] = useCallback(
|
||||
(e: ExpoImage.ImageErrorEventData) => {
|
||||
onError?.(e);
|
||||
|
||||
/* already swapped or dealing with a multi‑src array */
|
||||
if (fallbackSource || Array.isArray(source)) return;
|
||||
|
||||
// prevent it from recursing
|
||||
if (
|
||||
source &&
|
||||
typeof source === 'object' &&
|
||||
'uri' in source &&
|
||||
source?.uri?.startsWith('data:')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
/* try to infer a sensible grid size */
|
||||
const finalStyle = Array.isArray(style) ? Object.assign({}, ...style) : style;
|
||||
const width = finalStyle?.width ?? 128;
|
||||
const height = finalStyle?.height ?? 128;
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
setFallbackSource({ uri: buildGridPlaceholder(width, height) });
|
||||
} else {
|
||||
setFallbackSource(require('../../src/__create/placeholder.svg'));
|
||||
}
|
||||
},
|
||||
[source, fallbackSource, onError, style]
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpoImage.Image {...props} source={fallbackSource ?? source} ref={ref} onError={handleError} />
|
||||
);
|
||||
});
|
||||
|
||||
/* expose static helpers so nothing breaks */
|
||||
Object.assign(WrappedImage, ExpoImage);
|
||||
|
||||
/* re‑export everything that expo-image provides */
|
||||
export * from 'expo-image';
|
||||
export const Image = WrappedImage;
|
||||
export default Image;
|
||||
38
apps/mobile/polyfills/web/SafeAreaView.web.tsx
Normal file
38
apps/mobile/polyfills/web/SafeAreaView.web.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { forwardRef, type ReactNode } from 'react';
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { SafeAreaView as NativeSafeAreaView } from 'react-native-safe-area-context/lib/commonjs';
|
||||
export {
|
||||
initialWindowMetrics,
|
||||
SafeAreaFrameContext,
|
||||
SafeAreaInsetsContext,
|
||||
SafeAreaProvider,
|
||||
useSafeAreaFrame,
|
||||
} from 'react-native-safe-area-context/lib/commonjs';
|
||||
|
||||
type Edge = 'top' | 'right' | 'bottom' | 'left';
|
||||
type Edges = Edge[] | Record<Edge, 'off' | 'additive' | 'maximum'>;
|
||||
|
||||
interface SafeAreaViewProps {
|
||||
children?: ReactNode;
|
||||
edges?: Edges;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export const SafeAreaView = forwardRef<View, SafeAreaViewProps>(
|
||||
({ children, edges = ['top', 'right', 'bottom', 'left'] as Edges, ...rest }, forwardedRef) => {
|
||||
const isTabletAndAbove = typeof window !== 'undefined' ? window.self !== window.top : true;
|
||||
return (
|
||||
<NativeSafeAreaView {...rest} edges={edges} ref={forwardedRef}>
|
||||
{isTabletAndAbove && (Array.isArray(edges) && (edges as Edge[]).includes('top') || (!Array.isArray(edges) && (edges as Record<Edge, string>).top !== 'off')) && (
|
||||
<View style={{ height: 64 }} />
|
||||
)}
|
||||
{children}
|
||||
{isTabletAndAbove && (Array.isArray(edges) && (edges as Edge[]).includes('bottom') || (!Array.isArray(edges) && (edges as Record<Edge, string>).bottom !== 'off')) && (
|
||||
<View style={{ height: 34 }} />
|
||||
)}
|
||||
</NativeSafeAreaView>
|
||||
);
|
||||
}
|
||||
);
|
||||
export default SafeAreaView;
|
||||
521
apps/mobile/polyfills/web/alerts.web.tsx
Normal file
521
apps/mobile/polyfills/web/alerts.web.tsx
Normal file
@@ -0,0 +1,521 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
Modal,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
} from 'react-native';
|
||||
|
||||
type AlertButton = {
|
||||
text: string;
|
||||
onPress?: (value?: string | { login: string; password: string }) => void;
|
||||
style: 'cancel' | 'destructive' | 'default';
|
||||
};
|
||||
|
||||
type AlertOptions = {
|
||||
userInterfaceStyle: string;
|
||||
};
|
||||
|
||||
type AlertType = 'default' | 'plain-text' | 'secure-text' | 'login-password';
|
||||
|
||||
let globalAlertData = {
|
||||
visible: false,
|
||||
title: '',
|
||||
message: '',
|
||||
buttons: [{ text: 'OK', onPress: () => {}, style: 'default' }],
|
||||
userInterfaceStyle: 'light',
|
||||
};
|
||||
let setGlobalAlert: ((data: typeof globalAlertData) => void) | null = null;
|
||||
|
||||
let globalPromptData = {
|
||||
visible: false,
|
||||
title: '',
|
||||
message: '',
|
||||
callbackOrButtons: [{ text: 'OK', onPress: () => {}, style: 'default' }],
|
||||
type: 'default',
|
||||
defaultValue: '',
|
||||
keyboardType: 'default',
|
||||
userInterfaceStyle: 'light',
|
||||
};
|
||||
let setGlobalPrompt: ((data: typeof globalPromptData) => void) | null = null;
|
||||
|
||||
const processButtons = (
|
||||
buttons?: AlertButton[],
|
||||
includeCancel = false
|
||||
): AlertButton[] => {
|
||||
let processedButtons =
|
||||
buttons && buttons.length > 0
|
||||
? buttons.map((button) => ({ ...button, onPress: button.onPress || (() => {}) }))
|
||||
: includeCancel
|
||||
? [
|
||||
{ text: 'Cancel', onPress: () => {}, style: 'cancel' as const },
|
||||
{ text: 'OK', onPress: () => {}, style: 'default' as const },
|
||||
]
|
||||
: [{ text: 'OK', onPress: () => {}, style: 'default' as const }];
|
||||
|
||||
// cancel button should always be the last button unless there are two buttons
|
||||
if (processedButtons.length === 2) {
|
||||
const cancelIndex = processedButtons.findIndex(
|
||||
(btn) => btn.style === 'cancel'
|
||||
);
|
||||
if (cancelIndex === 1) {
|
||||
processedButtons = [processedButtons[1], processedButtons[0]];
|
||||
}
|
||||
} else if (processedButtons.length >= 3) {
|
||||
const cancelIndex = processedButtons.findLastIndex(
|
||||
(btn) => btn.style === 'cancel'
|
||||
);
|
||||
if (cancelIndex !== -1 && cancelIndex !== processedButtons.length - 1) {
|
||||
const cancelButton = processedButtons[cancelIndex];
|
||||
const otherButtons = processedButtons.filter(
|
||||
(_, index) => index !== cancelIndex
|
||||
);
|
||||
processedButtons = [...otherButtons, cancelButton];
|
||||
}
|
||||
}
|
||||
|
||||
return processedButtons;
|
||||
};
|
||||
|
||||
const Alert = {
|
||||
alert(
|
||||
title: string,
|
||||
message: string,
|
||||
buttons?: AlertButton[],
|
||||
userInterfaceStyle?: AlertOptions
|
||||
) {
|
||||
const processedButtons = processButtons(buttons);
|
||||
|
||||
globalAlertData = {
|
||||
visible: true,
|
||||
title: title,
|
||||
message: message || '',
|
||||
buttons: processedButtons.map((button) => ({
|
||||
...button,
|
||||
onPress: button.onPress || (() => {}),
|
||||
})),
|
||||
userInterfaceStyle: userInterfaceStyle?.userInterfaceStyle || 'light',
|
||||
};
|
||||
if (setGlobalAlert) {
|
||||
setGlobalAlert({ ...globalAlertData });
|
||||
}
|
||||
},
|
||||
|
||||
prompt(
|
||||
title: string,
|
||||
message?: string,
|
||||
callbackOrButtons?: AlertButton[],
|
||||
type?: AlertType,
|
||||
defaultValue?: string,
|
||||
keyboardType?: string,
|
||||
userInterfaceStyle?: AlertOptions
|
||||
) {
|
||||
const processedButtons = processButtons(callbackOrButtons, true);
|
||||
|
||||
globalPromptData = {
|
||||
visible: true,
|
||||
title: title,
|
||||
message: message || '',
|
||||
callbackOrButtons: processedButtons.map((button) => ({
|
||||
...button,
|
||||
onPress: button.onPress || (() => {}),
|
||||
})),
|
||||
type: type || 'plain-text',
|
||||
defaultValue: defaultValue || '',
|
||||
keyboardType: keyboardType || 'default',
|
||||
userInterfaceStyle: userInterfaceStyle?.userInterfaceStyle || 'light',
|
||||
};
|
||||
if (setGlobalPrompt) {
|
||||
setGlobalPrompt({ ...globalPromptData });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const AlertModal = () => {
|
||||
const [alertData, setAlertData] = useState(globalAlertData);
|
||||
const [promptData, setPromptData] = useState(globalPromptData);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [currentModalData, setCurrentModalData] = useState<{
|
||||
visible: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
buttons: {
|
||||
text: string;
|
||||
onPress: (value?: string | { login: string; password: string }) => void;
|
||||
style: string;
|
||||
}[];
|
||||
userInterfaceStyle: string;
|
||||
isPrompt: boolean;
|
||||
type?: string;
|
||||
defaultValue?: string;
|
||||
keyboardType?: string;
|
||||
} | null>(null);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [loginValue, setLoginValue] = useState('');
|
||||
const scaleAnim = useRef(new Animated.Value(1.25)).current;
|
||||
const opacityAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
const showModal = () => {
|
||||
setModalVisible(true);
|
||||
Animated.parallel([
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
};
|
||||
|
||||
if (promptData.visible) {
|
||||
setCurrentModalData({
|
||||
...promptData,
|
||||
isPrompt: true,
|
||||
buttons: promptData.callbackOrButtons,
|
||||
});
|
||||
showModal();
|
||||
} else if (alertData.visible) {
|
||||
setCurrentModalData({
|
||||
...alertData,
|
||||
buttons: alertData.buttons,
|
||||
isPrompt: false,
|
||||
});
|
||||
showModal();
|
||||
} else {
|
||||
setCurrentModalData(null);
|
||||
}
|
||||
}, [
|
||||
promptData.visible,
|
||||
alertData.visible,
|
||||
promptData,
|
||||
alertData,
|
||||
opacityAnim,
|
||||
scaleAnim,
|
||||
]);
|
||||
|
||||
const modalData = currentModalData || {
|
||||
...alertData,
|
||||
isPrompt: false,
|
||||
buttons: alertData.buttons,
|
||||
type: 'default',
|
||||
defaultValue: '',
|
||||
keyboardType: 'default',
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setGlobalAlert = setAlertData;
|
||||
setGlobalPrompt = setPromptData;
|
||||
return () => {
|
||||
setGlobalAlert = null;
|
||||
setGlobalPrompt = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeModal = () => {
|
||||
Animated.timing(opacityAnim, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
setModalVisible(false);
|
||||
scaleAnim.setValue(1.25);
|
||||
setInputValue('');
|
||||
setLoginValue('');
|
||||
globalAlertData = {
|
||||
visible: false,
|
||||
title: '',
|
||||
message: '',
|
||||
buttons: [],
|
||||
userInterfaceStyle: 'light',
|
||||
};
|
||||
globalPromptData = {
|
||||
visible: false,
|
||||
title: '',
|
||||
message: '',
|
||||
callbackOrButtons: [],
|
||||
type: 'default',
|
||||
defaultValue: '',
|
||||
keyboardType: 'default',
|
||||
userInterfaceStyle: 'light',
|
||||
};
|
||||
setAlertData(globalAlertData);
|
||||
setPromptData(globalPromptData);
|
||||
setCurrentModalData(null);
|
||||
});
|
||||
};
|
||||
|
||||
const styles = styling(modalData.userInterfaceStyle);
|
||||
|
||||
return (
|
||||
<Modal visible={modalVisible} transparent animationType="none">
|
||||
<Animated.View style={[styles.container, { opacity: opacityAnim }]}>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.content,
|
||||
{ transform: [{ scale: scaleAnim }] },
|
||||
modalData.userInterfaceStyle === 'dark'
|
||||
? {
|
||||
backgroundColor: 'rgba(0,0,0,0.65)',
|
||||
}
|
||||
: { backgroundColor: 'rgba(255, 255, 255, 0.75)' },
|
||||
]}
|
||||
>
|
||||
<View style={styles.contentContainer}>
|
||||
<Text
|
||||
style={[
|
||||
styles.title,
|
||||
modalData.userInterfaceStyle === 'dark' && {
|
||||
color: 'white',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{modalData.title}
|
||||
</Text>
|
||||
{modalData.message ? (
|
||||
<Text
|
||||
style={[
|
||||
styles.message,
|
||||
modalData.userInterfaceStyle === 'dark' && {
|
||||
color: 'white',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{modalData.message}
|
||||
</Text>
|
||||
) : null}
|
||||
{modalData?.isPrompt && modalData.type !== 'default' ? (
|
||||
<View>
|
||||
{modalData.type === 'login-password' ? (
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
styles.textInputTop,
|
||||
modalData.userInterfaceStyle === 'dark'
|
||||
? {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
color: 'white',
|
||||
borderColor: 'rgba(255,255,255,0.3)',
|
||||
}
|
||||
: {
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
color: 'black',
|
||||
borderColor: 'rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
]}
|
||||
value={loginValue}
|
||||
onChangeText={setLoginValue}
|
||||
placeholder="Login"
|
||||
placeholderTextColor={
|
||||
modalData.userInterfaceStyle === 'dark'
|
||||
? 'rgba(255,255,255,0.5)'
|
||||
: 'rgba(0,0,0,0.5)'
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
) : null}
|
||||
<TextInput
|
||||
style={[
|
||||
styles.textInput,
|
||||
modalData.type === 'login-password' &&
|
||||
styles.textInputBottom,
|
||||
modalData.userInterfaceStyle === 'dark'
|
||||
? {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
color: 'white',
|
||||
borderColor: 'rgba(255,255,255,0.3)',
|
||||
}
|
||||
: {
|
||||
backgroundColor: 'rgba(255,255,255,0.9)',
|
||||
color: 'black',
|
||||
borderColor: 'rgba(0, 0, 0, 0.2)',
|
||||
},
|
||||
]}
|
||||
value={inputValue}
|
||||
onChangeText={setInputValue}
|
||||
placeholder={(() => {
|
||||
switch (modalData.type) {
|
||||
case 'plain-text':
|
||||
return '';
|
||||
case 'secure-text':
|
||||
case 'login-password':
|
||||
return 'Password';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
})()}
|
||||
placeholderTextColor={
|
||||
modalData.userInterfaceStyle === 'dark'
|
||||
? 'rgba(255,255,255,0.5)'
|
||||
: 'rgba(0,0,0,0.5)'
|
||||
}
|
||||
secureTextEntry={
|
||||
modalData.type === 'secure-text' ||
|
||||
modalData.type === 'login-password'
|
||||
}
|
||||
keyboardType={
|
||||
modalData.keyboardType === 'numeric' ? 'numeric' : 'default'
|
||||
}
|
||||
autoFocus={modalData.type !== 'login-password'}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
modalData.buttons.length >= 3
|
||||
? styles.buttonColumnContainer
|
||||
: styles.buttonRowContainer,
|
||||
modalData.buttons.length <= 2 && styles.buttonTopBorder,
|
||||
]}
|
||||
>
|
||||
{modalData.buttons.map((button, index) => (
|
||||
<TouchableOpacity
|
||||
key={`${button.text}-${index}`}
|
||||
onPress={() => {
|
||||
if (modalData?.isPrompt) {
|
||||
let valueToPass:
|
||||
| string
|
||||
| { login: string; password: string } = inputValue;
|
||||
if (modalData.type === 'login-password') {
|
||||
valueToPass = {
|
||||
login: loginValue,
|
||||
password: inputValue,
|
||||
};
|
||||
}
|
||||
button.onPress(valueToPass);
|
||||
} else {
|
||||
button.onPress();
|
||||
}
|
||||
closeModal();
|
||||
}}
|
||||
style={[
|
||||
styles.button,
|
||||
modalData.buttons.length >= 3 && styles.buttonTopBorder,
|
||||
modalData.buttons.length === 2 && { width: '50%' },
|
||||
modalData.buttons.length <= 1 && { width: '100%' },
|
||||
index === 0 &&
|
||||
modalData.buttons.length === 2 && {
|
||||
borderRightWidth: 1,
|
||||
borderColor:
|
||||
modalData.userInterfaceStyle === 'dark'
|
||||
? 'rgba(255,255,255,0.2)'
|
||||
: 'lightgray',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.buttonText,
|
||||
button.style === 'cancel' && { fontWeight: '600' },
|
||||
button.style === 'destructive' && { color: 'red' },
|
||||
]}
|
||||
>
|
||||
{button.text}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styling = (userInterfaceStyle: string) =>
|
||||
StyleSheet.create<Record<string, any>>({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(0,0,0,0.2)',
|
||||
},
|
||||
content: {
|
||||
backdropFilter: 'blur(20px)' as any,
|
||||
borderRadius: 12,
|
||||
width: 244,
|
||||
},
|
||||
contentContainer: {
|
||||
paddingVertical: 20,
|
||||
paddingHorizontal: 12,
|
||||
gap: 4,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
message: {
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
button: {
|
||||
paddingVertical: 12,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#007AFF',
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
},
|
||||
textInput: {
|
||||
borderWidth: 0.5,
|
||||
borderColor: 'lightgray',
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
marginTop: 16,
|
||||
marginBottom: -8,
|
||||
marginHorizontal: 12,
|
||||
fontSize: 12,
|
||||
outlineStyle: 'none' as any,
|
||||
},
|
||||
textInputTop: {
|
||||
borderTopLeftRadius: 8,
|
||||
borderTopRightRadius: 8,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
borderBottomWidth: 0,
|
||||
marginBottom: 0,
|
||||
},
|
||||
textInputBottom: {
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomLeftRadius: 8,
|
||||
borderBottomRightRadius: 8,
|
||||
marginTop: 0,
|
||||
},
|
||||
buttonTopBorder: {
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor:
|
||||
userInterfaceStyle === 'dark'
|
||||
? 'rgba(255,255,255,0.2)'
|
||||
: 'lightgray',
|
||||
},
|
||||
buttonRowContainer: {
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor:
|
||||
userInterfaceStyle === 'dark'
|
||||
? 'rgba(255,255,255,0.2)'
|
||||
: 'lightgray',
|
||||
},
|
||||
buttonColumnContainer: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
buttonRightBorder: {
|
||||
borderRightWidth: 0.5,
|
||||
borderRightColor:
|
||||
userInterfaceStyle === 'dark'
|
||||
? 'rgba(255,255,255,0.2)'
|
||||
: 'lightgray',
|
||||
},
|
||||
});
|
||||
|
||||
export default Alert;
|
||||
241
apps/mobile/polyfills/web/camera.web.tsx
Normal file
241
apps/mobile/polyfills/web/camera.web.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { View, Text, StyleSheet, type ViewStyle } from 'react-native';
|
||||
|
||||
export enum CameraType {
|
||||
front = 'front',
|
||||
back = 'back',
|
||||
}
|
||||
|
||||
export enum FlashMode {
|
||||
off = 'off',
|
||||
on = 'on',
|
||||
auto = 'auto',
|
||||
torch = 'torch',
|
||||
}
|
||||
|
||||
export enum CameraMode {
|
||||
picture = 'picture',
|
||||
video = 'video',
|
||||
}
|
||||
|
||||
interface CameraViewProps {
|
||||
style?: ViewStyle;
|
||||
facing?: 'front' | 'back';
|
||||
flash?: 'off' | 'on' | 'auto';
|
||||
mode?: 'picture' | 'video';
|
||||
zoom?: number;
|
||||
enableTorch?: boolean;
|
||||
onCameraReady?: () => void;
|
||||
onMountError?: (event: { message: string }) => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface CameraViewRef {
|
||||
takePictureAsync: (options?: {
|
||||
quality?: number;
|
||||
base64?: boolean;
|
||||
}) => Promise<{ uri: string; width: number; height: number; base64?: string }>;
|
||||
recordAsync: (options?: {
|
||||
maxDuration?: number;
|
||||
}) => Promise<{ uri: string }>;
|
||||
stopRecording: () => void;
|
||||
}
|
||||
|
||||
export const CameraView = forwardRef<CameraViewRef, CameraViewProps>(
|
||||
function CameraView(
|
||||
{ style, facing = 'back', onCameraReady, onMountError, children },
|
||||
ref
|
||||
) {
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const [hasCamera, setHasCamera] = useState(true);
|
||||
|
||||
const startCamera = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: facing === 'front' ? 'user' : 'environment',
|
||||
},
|
||||
});
|
||||
streamRef.current = stream;
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
await videoRef.current.play();
|
||||
}
|
||||
onCameraReady?.();
|
||||
} catch (err) {
|
||||
setHasCamera(false);
|
||||
onMountError?.({
|
||||
message:
|
||||
err instanceof Error ? err.message : 'Camera not available',
|
||||
});
|
||||
}
|
||||
}, [facing, onCameraReady, onMountError]);
|
||||
|
||||
useEffect(() => {
|
||||
void startCamera();
|
||||
return () => {
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
};
|
||||
}, [startCamera]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
takePictureAsync: async (options) => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !streamRef.current) {
|
||||
throw new Error('Camera is not ready');
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(video, 0, 0);
|
||||
const quality = options?.quality ?? 0.85;
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', quality);
|
||||
const result: {
|
||||
uri: string;
|
||||
width: number;
|
||||
height: number;
|
||||
base64?: string;
|
||||
} = {
|
||||
uri: dataUrl,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
};
|
||||
if (options?.base64) {
|
||||
result.base64 = dataUrl.split(',')[1];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
recordAsync: async () => {
|
||||
throw new Error('Video recording is not supported in web preview');
|
||||
},
|
||||
stopRecording: () => {},
|
||||
}));
|
||||
|
||||
if (!hasCamera) {
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<View style={styles.placeholder}>
|
||||
<Text style={styles.placeholderText}>Camera</Text>
|
||||
<Text style={styles.placeholderSubtext}>
|
||||
Not available in this browser
|
||||
</Text>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const grantedPermission = {
|
||||
status: 'granted' as const,
|
||||
granted: true,
|
||||
canAskAgain: true,
|
||||
expires: 'never' as const,
|
||||
};
|
||||
|
||||
const deniedPermission = {
|
||||
status: 'denied' as const,
|
||||
granted: false,
|
||||
canAskAgain: true,
|
||||
expires: 'never' as const,
|
||||
};
|
||||
|
||||
export function useCameraPermissions(): [
|
||||
{ status: string; granted: boolean; canAskAgain: boolean } | null,
|
||||
() => Promise<{ status: string; granted: boolean; canAskAgain: boolean }>,
|
||||
] {
|
||||
const [permission, setPermission] = useState<{
|
||||
status: string;
|
||||
granted: boolean;
|
||||
canAskAgain: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const requestPermission = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
setPermission(grantedPermission);
|
||||
return grantedPermission;
|
||||
} catch {
|
||||
setPermission(deniedPermission);
|
||||
return deniedPermission;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return [permission, requestPermission];
|
||||
}
|
||||
|
||||
export async function requestCameraPermissionsAsync() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return grantedPermission;
|
||||
} catch {
|
||||
return deniedPermission;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCameraPermissionsAsync() {
|
||||
try {
|
||||
const result = await navigator.permissions.query({
|
||||
name: 'camera' as PermissionName,
|
||||
});
|
||||
return result.state === 'granted' ? grantedPermission : deniedPermission;
|
||||
} catch {
|
||||
return deniedPermission;
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
placeholder: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#1a1a1a',
|
||||
},
|
||||
placeholderText: {
|
||||
color: '#999',
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
placeholderSubtext: {
|
||||
color: '#666',
|
||||
fontSize: 13,
|
||||
},
|
||||
});
|
||||
49
apps/mobile/polyfills/web/clipboard.web.ts
Normal file
49
apps/mobile/polyfills/web/clipboard.web.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export async function getStringAsync(): Promise<string> {
|
||||
try {
|
||||
return await navigator.clipboard.readText();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function setStringAsync(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasStringAsync(): Promise<boolean> {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
return text.length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getImageAsync() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function setImageAsync() {}
|
||||
|
||||
export async function hasImageAsync(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function addClipboardListener(
|
||||
_listener: (event: { contentTypes: string[] }) => void
|
||||
) {
|
||||
return { remove: () => {} };
|
||||
}
|
||||
|
||||
export function removeClipboardListener(subscription: { remove: () => void }) {
|
||||
subscription.remove();
|
||||
}
|
||||
|
||||
export function isPlatformSupported(): boolean {
|
||||
return typeof navigator !== 'undefined' && !!navigator.clipboard;
|
||||
}
|
||||
299
apps/mobile/polyfills/web/contacts.web.ts
Normal file
299
apps/mobile/polyfills/web/contacts.web.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import type { ExistingContact, ContactQuery } from 'expo-contacts';
|
||||
import { Fields, SortTypes } from 'expo-contacts/src/Contacts';
|
||||
import Alert from './alerts.web';
|
||||
import * as Notifications from 'expo-contacts';
|
||||
const { PermissionStatus } = Notifications;
|
||||
|
||||
export { PermissionStatus, Fields, SortTypes };
|
||||
|
||||
const fakeContacts: ExistingContact[] = [
|
||||
{
|
||||
id: '1',
|
||||
contactType: 'person',
|
||||
name: 'John Doe',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
phoneNumbers: [
|
||||
{ number: '+1 (555) 123-4567', isPrimary: true, label: 'mobile' },
|
||||
{ number: '+1 (555) 987-6543', isPrimary: false, label: 'home' },
|
||||
],
|
||||
emails: [
|
||||
{ email: 'john.doe@example.com', isPrimary: true, label: 'work' },
|
||||
{ email: 'john.personal@gmail.com', isPrimary: false, label: 'personal' },
|
||||
],
|
||||
addresses: [
|
||||
{
|
||||
street: '123 Main St',
|
||||
city: 'New York',
|
||||
region: 'NY',
|
||||
postalCode: '10001',
|
||||
country: 'USA',
|
||||
label: 'home',
|
||||
},
|
||||
],
|
||||
birthday: { day: 15, month: 5, year: 1990 },
|
||||
note: 'Met at conference',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
contactType: 'person',
|
||||
name: 'Jane Smith',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
phoneNumbers: [{ number: '+1 (555) 234-5678', isPrimary: true, label: 'mobile' }],
|
||||
emails: [{ email: 'jane.smith@company.com', isPrimary: true, label: 'work' }],
|
||||
addresses: [
|
||||
{
|
||||
street: '456 Oak Ave',
|
||||
city: 'Los Angeles',
|
||||
region: 'CA',
|
||||
postalCode: '90210',
|
||||
country: 'USA',
|
||||
label: 'home',
|
||||
},
|
||||
],
|
||||
birthday: { day: 3, month: 12, year: 1985 },
|
||||
note: 'College friend',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
contactType: 'person',
|
||||
name: 'Bob Johnson',
|
||||
firstName: 'Bob',
|
||||
lastName: 'Johnson',
|
||||
phoneNumbers: [{ number: '+1 (555) 345-6789', isPrimary: true, label: 'mobile' }],
|
||||
emails: [{ email: 'bob.johnson@email.com', isPrimary: true, label: 'personal' }],
|
||||
addresses: [],
|
||||
birthday: { day: 22, month: 8, year: 1992 },
|
||||
note: 'Neighbor',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
contactType: 'person',
|
||||
name: 'Alice Williams',
|
||||
firstName: 'Alice',
|
||||
lastName: 'Williams',
|
||||
phoneNumbers: [
|
||||
{ number: '+1 (555) 456-7890', isPrimary: true, label: 'mobile' },
|
||||
{ number: '+1 (555) 111-2222', isPrimary: false, label: 'work' },
|
||||
],
|
||||
emails: [{ email: 'alice.williams@startup.com', isPrimary: true, label: 'work' }],
|
||||
addresses: [
|
||||
{
|
||||
street: '789 Pine St',
|
||||
city: 'San Francisco',
|
||||
region: 'CA',
|
||||
postalCode: '94102',
|
||||
country: 'USA',
|
||||
label: 'work',
|
||||
},
|
||||
],
|
||||
birthday: { day: 10, month: 3, year: 1988 },
|
||||
note: 'Business partner',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
contactType: 'person',
|
||||
name: 'Charlie Brown',
|
||||
firstName: 'Charlie',
|
||||
lastName: 'Brown',
|
||||
phoneNumbers: [{ number: '+1 (555) 567-8901', isPrimary: true, label: 'mobile' }],
|
||||
emails: [{ email: 'charlie.brown@gmail.com', isPrimary: true, label: 'personal' }],
|
||||
addresses: [],
|
||||
birthday: { day: 18, month: 11, year: 1995 },
|
||||
note: 'Gym buddy',
|
||||
},
|
||||
];
|
||||
|
||||
let permissionStatus = {
|
||||
status: PermissionStatus.UNDETERMINED,
|
||||
expires: 'never',
|
||||
granted: false,
|
||||
canAskAgain: true,
|
||||
};
|
||||
|
||||
// since we polyfill fake contacts, we always return true
|
||||
export const isAvailableAsync = async () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
export const requestPermissionsAsync = async () => {
|
||||
if (permissionStatus.status === PermissionStatus.GRANTED) {
|
||||
return permissionStatus;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
Alert.alert(
|
||||
'"Expo Go" Would Like to Access Your Contacts',
|
||||
'Allow Expo projects to access your contacts',
|
||||
[
|
||||
{
|
||||
text: "Don't Allow",
|
||||
onPress: () => {
|
||||
permissionStatus = {
|
||||
status: PermissionStatus.DENIED,
|
||||
expires: 'never',
|
||||
granted: false,
|
||||
canAskAgain: true,
|
||||
};
|
||||
resolve(permissionStatus);
|
||||
},
|
||||
style: 'default',
|
||||
},
|
||||
{
|
||||
text: 'Continue',
|
||||
onPress: () => {
|
||||
permissionStatus = {
|
||||
status: PermissionStatus.GRANTED,
|
||||
expires: 'never',
|
||||
granted: true,
|
||||
canAskAgain: false,
|
||||
};
|
||||
resolve(permissionStatus);
|
||||
},
|
||||
style: 'default',
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const getPermissionsAsync = async () => {
|
||||
return permissionStatus;
|
||||
};
|
||||
|
||||
export const getContactsAsync = async (options: ContactQuery = {}) => {
|
||||
const { sort = SortTypes.FirstName, pageSize, pageOffset } = options;
|
||||
|
||||
let contacts = [...fakeContacts];
|
||||
|
||||
if (sort === SortTypes.FirstName) {
|
||||
contacts.sort((a, b) => (a.firstName || '').localeCompare(b.firstName || ''));
|
||||
} else if (sort === SortTypes.LastName) {
|
||||
contacts.sort((a, b) => (a.lastName || '').localeCompare(b.lastName || ''));
|
||||
}
|
||||
|
||||
if (pageSize && pageOffset !== undefined) {
|
||||
const startIndex = pageOffset * pageSize;
|
||||
contacts = contacts.slice(startIndex, startIndex + pageSize);
|
||||
}
|
||||
|
||||
return {
|
||||
data: contacts,
|
||||
hasNextPage: false,
|
||||
hasPreviousPage: false,
|
||||
total: fakeContacts.length,
|
||||
};
|
||||
};
|
||||
|
||||
export const getContactByIdAsync = async (id: string, _options: ContactQuery = {}) => {
|
||||
const contact = fakeContacts.find((c) => c.id === id);
|
||||
if (!contact) {
|
||||
throw new Error(`Contact with id ${id} not found`);
|
||||
}
|
||||
|
||||
return contact;
|
||||
};
|
||||
|
||||
export const addContactAsync = async (contact: ExistingContact) => {
|
||||
const newContact: ExistingContact = {
|
||||
id: Date.now().toString(),
|
||||
contactType: contact.contactType || 'person',
|
||||
name: contact.name || '',
|
||||
firstName: contact.firstName || '',
|
||||
lastName: contact.lastName || '',
|
||||
phoneNumbers: contact.phoneNumbers || [],
|
||||
emails: contact.emails || [],
|
||||
addresses: contact.addresses || [],
|
||||
birthday: contact.birthday,
|
||||
note: contact.note || '',
|
||||
middleName: contact.middleName,
|
||||
maidenName: contact.maidenName,
|
||||
namePrefix: contact.namePrefix,
|
||||
nameSuffix: contact.nameSuffix,
|
||||
nickname: contact.nickname,
|
||||
phoneticFirstName: contact.phoneticFirstName,
|
||||
phoneticMiddleName: contact.phoneticMiddleName,
|
||||
phoneticLastName: contact.phoneticLastName,
|
||||
company: contact.company,
|
||||
jobTitle: contact.jobTitle,
|
||||
department: contact.department,
|
||||
imageAvailable: contact.imageAvailable,
|
||||
image: contact.image,
|
||||
rawImage: contact.rawImage,
|
||||
dates: contact.dates,
|
||||
relationships: contact.relationships,
|
||||
instantMessageAddresses: contact.instantMessageAddresses,
|
||||
urlAddresses: contact.urlAddresses,
|
||||
nonGregorianBirthday: contact.nonGregorianBirthday,
|
||||
socialProfiles: contact.socialProfiles,
|
||||
isFavorite: contact.isFavorite,
|
||||
};
|
||||
|
||||
fakeContacts.push(newContact);
|
||||
Alert.alert('Success', 'Contact added successfully!');
|
||||
return newContact.id;
|
||||
};
|
||||
|
||||
export const updateContactAsync = async (contact: ExistingContact) => {
|
||||
const index = fakeContacts.findIndex((c) => c.id === contact.id);
|
||||
if (index === -1) {
|
||||
throw new Error(`Contact with id ${contact.id} not found`);
|
||||
}
|
||||
|
||||
fakeContacts[index] = { ...fakeContacts[index], ...contact };
|
||||
return contact.id;
|
||||
};
|
||||
|
||||
export const removeContactAsync = async (contactId: string) => {
|
||||
const index = fakeContacts.findIndex((c) => c.id === contactId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Contact with id ${contactId} not found`);
|
||||
}
|
||||
|
||||
fakeContacts.splice(index, 1);
|
||||
setTimeout(() => {
|
||||
Alert.alert('Success', 'Contact deleted successfully!');
|
||||
}, 500);
|
||||
return contactId;
|
||||
};
|
||||
|
||||
const _createNoOpAsync = async () => {
|
||||
Alert.alert('Not supported in the builder', 'Please use the Expo Go app to test this feature');
|
||||
return { type: 'custom', data: null };
|
||||
};
|
||||
|
||||
export const presentContactPickerAsync = async () => {
|
||||
return _createNoOpAsync();
|
||||
};
|
||||
export const getGroupsAsync = async () => {
|
||||
return _createNoOpAsync();
|
||||
};
|
||||
export const createGroupAsync = async () => {
|
||||
return _createNoOpAsync();
|
||||
};
|
||||
export const removeGroupAsync = async () => {
|
||||
return _createNoOpAsync();
|
||||
};
|
||||
export const updateGroupNameAsync = async () => {
|
||||
return _createNoOpAsync();
|
||||
};
|
||||
|
||||
export default {
|
||||
Fields,
|
||||
SortTypes,
|
||||
PermissionStatus,
|
||||
isAvailableAsync,
|
||||
requestPermissionsAsync,
|
||||
getPermissionsAsync,
|
||||
getContactsAsync,
|
||||
getContactByIdAsync,
|
||||
addContactAsync,
|
||||
updateContactAsync,
|
||||
removeContactAsync,
|
||||
presentContactPickerAsync,
|
||||
getGroupsAsync,
|
||||
createGroupAsync,
|
||||
removeGroupAsync,
|
||||
updateGroupNameAsync,
|
||||
};
|
||||
94
apps/mobile/polyfills/web/documentPicker.web.ts
Normal file
94
apps/mobile/polyfills/web/documentPicker.web.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
interface DocumentPickerAsset {
|
||||
name: string;
|
||||
size: number | null;
|
||||
uri: string;
|
||||
mimeType: string | null;
|
||||
}
|
||||
|
||||
interface DocumentPickerResult {
|
||||
canceled: boolean;
|
||||
assets: DocumentPickerAsset[];
|
||||
output: null;
|
||||
}
|
||||
|
||||
interface DocumentPickerOptions {
|
||||
type?: string | string[];
|
||||
copyToCacheDirectory?: boolean;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
export async function getDocumentAsync(
|
||||
options?: DocumentPickerOptions
|
||||
): Promise<DocumentPickerResult> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.multiple = options?.multiple ?? false;
|
||||
|
||||
if (options?.type) {
|
||||
const types = Array.isArray(options.type)
|
||||
? options.type
|
||||
: [options.type];
|
||||
const filtered = types.filter((t) => t !== '*/*');
|
||||
if (filtered.length > 0) {
|
||||
input.accept = filtered.join(',');
|
||||
}
|
||||
}
|
||||
|
||||
input.style.display = 'none';
|
||||
document.body.appendChild(input);
|
||||
|
||||
let resolved = false;
|
||||
const cleanup = () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
document.body.removeChild(input);
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
const files = input.files;
|
||||
if (!files || files.length === 0) {
|
||||
cleanup();
|
||||
resolve({ canceled: true, assets: [], output: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = Array.from(files).map(
|
||||
(file) =>
|
||||
new Promise<DocumentPickerAsset>((resolveAsset) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
resolveAsset({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
uri: reader.result as string,
|
||||
mimeType: file.type || null,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
})
|
||||
);
|
||||
|
||||
void Promise.all(promises).then((assets) => {
|
||||
cleanup();
|
||||
resolve({ canceled: false, assets, output: null });
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener(
|
||||
'focus',
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
cleanup();
|
||||
resolve({ canceled: true, assets: [], output: null });
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
10
apps/mobile/polyfills/web/expo-font.web.ts
Normal file
10
apps/mobile/polyfills/web/expo-font.web.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export * from 'expo-font';
|
||||
export { useFonts } from 'expo-font';
|
||||
|
||||
export async function renderToImageAsync(): Promise<{
|
||||
uri: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}> {
|
||||
return { uri: '', width: 0, height: 0 };
|
||||
}
|
||||
424
apps/mobile/polyfills/web/google-mobile-ads.web.tsx
Normal file
424
apps/mobile/polyfills/web/google-mobile-ads.web.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import type React from "react";
|
||||
import { Text, View, type ViewStyle } from "react-native";
|
||||
|
||||
// Stub for react-native-google-mobile-ads on web.
|
||||
// Ads are native-only; these render visual placeholders so users can preview
|
||||
// their layouts in Expo Go without the native module.
|
||||
|
||||
export const BannerAdSize = {
|
||||
BANNER: "BANNER",
|
||||
FULL_BANNER: "FULL_BANNER",
|
||||
LARGE_BANNER: "LARGE_BANNER",
|
||||
LEADERBOARD: "LEADERBOARD",
|
||||
MEDIUM_RECTANGLE: "MEDIUM_RECTANGLE",
|
||||
ADAPTIVE_BANNER: "ADAPTIVE_BANNER",
|
||||
ANCHORED_ADAPTIVE_BANNER: "ANCHORED_ADAPTIVE_BANNER",
|
||||
INLINE_ADAPTIVE_BANNER: "INLINE_ADAPTIVE_BANNER",
|
||||
WIDE_SKYSCRAPER: "WIDE_SKYSCRAPER",
|
||||
};
|
||||
|
||||
export const AdEventType = {
|
||||
LOADED: "loaded",
|
||||
ERROR: "error",
|
||||
OPENED: "opened",
|
||||
CLICKED: "clicked",
|
||||
CLOSED: "closed",
|
||||
};
|
||||
|
||||
export const RewardedAdEventType = {
|
||||
LOADED: "loaded",
|
||||
EARNED_REWARD: "earned_reward",
|
||||
};
|
||||
|
||||
export const AdsConsentStatus = {
|
||||
UNKNOWN: 0,
|
||||
REQUIRED: 1,
|
||||
NOT_REQUIRED: 2,
|
||||
OBTAINED: 3,
|
||||
};
|
||||
|
||||
export const AdsConsentDebugGeography = {
|
||||
DISABLED: 0,
|
||||
EEA: 1,
|
||||
NOT_EEA: 2,
|
||||
};
|
||||
|
||||
export const TestIds = {
|
||||
BANNER: "ca-app-pub-3940256099942544/6300978111",
|
||||
GAM_BANNER: "ca-app-pub-3940256099942544/6300978111",
|
||||
INTERSTITIAL: "ca-app-pub-3940256099942544/1033173712",
|
||||
GAM_INTERSTITIAL: "ca-app-pub-3940256099942544/1033173712",
|
||||
REWARDED: "ca-app-pub-3940256099942544/5224354917",
|
||||
REWARDED_INTERSTITIAL: "ca-app-pub-3940256099942544/5354046379",
|
||||
APP_OPEN: "ca-app-pub-3940256099942544/3419835294",
|
||||
NATIVE: "ca-app-pub-3940256099942544/2247696110",
|
||||
NATIVE_VIDEO: "ca-app-pub-3940256099942544/1044960115",
|
||||
};
|
||||
|
||||
const PLACEHOLDER_BG = "#f5f5f5";
|
||||
const PLACEHOLDER_BORDER = "#e0e0e0";
|
||||
const PLACEHOLDER_TEXT = "#999999";
|
||||
const AD_LABEL_BG = "#fbbc04";
|
||||
const AD_LABEL_TEXT = "#1a1a1a";
|
||||
|
||||
const AdLabel = () => (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: AD_LABEL_BG,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: "700",
|
||||
color: AD_LABEL_TEXT,
|
||||
lineHeight: 11,
|
||||
}}
|
||||
>
|
||||
Ad
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const getBannerStyle = (size: string | undefined): ViewStyle => {
|
||||
switch (size) {
|
||||
case "FULL_BANNER":
|
||||
return { width: 468, height: 60 };
|
||||
case "LARGE_BANNER":
|
||||
return { width: 320, height: 100 };
|
||||
case "LEADERBOARD":
|
||||
return { width: 728, height: 90 };
|
||||
case "MEDIUM_RECTANGLE":
|
||||
return { width: 300, height: 250 };
|
||||
case "WIDE_SKYSCRAPER":
|
||||
return { width: 160, height: 600 };
|
||||
case "ADAPTIVE_BANNER":
|
||||
case "ANCHORED_ADAPTIVE_BANNER":
|
||||
return { width: "100%", height: 50 };
|
||||
case "INLINE_ADAPTIVE_BANNER":
|
||||
return { width: "100%", height: 100 };
|
||||
default:
|
||||
return { width: 320, height: 50 };
|
||||
}
|
||||
};
|
||||
|
||||
type BannerAdProps = {
|
||||
size?: string;
|
||||
unitId?: string;
|
||||
onAdLoaded?: () => void;
|
||||
onAdFailedToLoad?: (error: unknown) => void;
|
||||
onAdOpened?: () => void;
|
||||
onAdClosed?: () => void;
|
||||
};
|
||||
|
||||
const BannerPlaceholder = ({
|
||||
size,
|
||||
label,
|
||||
}: { size?: string; label: string }) => {
|
||||
const dims = getBannerStyle(size);
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
...dims,
|
||||
backgroundColor: PLACEHOLDER_BG,
|
||||
borderWidth: 1,
|
||||
borderColor: PLACEHOLDER_BORDER,
|
||||
borderRadius: 4,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexDirection: "row",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<AdLabel />
|
||||
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>{label}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const BannerAd = ({ size }: BannerAdProps) => (
|
||||
<BannerPlaceholder size={size} label="Banner Ad" />
|
||||
);
|
||||
|
||||
export const GAMBannerAd = ({ size }: BannerAdProps) => (
|
||||
<BannerPlaceholder size={size} label="Ad Manager Banner" />
|
||||
);
|
||||
|
||||
type NativeAdViewProps = {
|
||||
children?: React.ReactNode;
|
||||
nativeAd?: unknown;
|
||||
style?: ViewStyle | ViewStyle[];
|
||||
};
|
||||
|
||||
const DefaultNativeAdContent = () => (
|
||||
<View>
|
||||
<View
|
||||
style={{ flexDirection: "row", alignItems: "center", marginBottom: 10 }}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
backgroundColor: PLACEHOLDER_BORDER,
|
||||
marginRight: 10,
|
||||
}}
|
||||
/>
|
||||
<View style={{ flex: 1 }}>
|
||||
<View
|
||||
style={{
|
||||
height: 12,
|
||||
backgroundColor: PLACEHOLDER_BORDER,
|
||||
borderRadius: 4,
|
||||
marginBottom: 6,
|
||||
width: "70%",
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: 10,
|
||||
backgroundColor: "#ececec",
|
||||
borderRadius: 4,
|
||||
width: "40%",
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
height: 140,
|
||||
backgroundColor: PLACEHOLDER_BORDER,
|
||||
borderRadius: 4,
|
||||
marginBottom: 10,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>
|
||||
Native Ad Media
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
height: 10,
|
||||
backgroundColor: "#ececec",
|
||||
borderRadius: 4,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
height: 10,
|
||||
backgroundColor: "#ececec",
|
||||
borderRadius: 4,
|
||||
width: "80%",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
alignSelf: "flex-start",
|
||||
backgroundColor: "#1a73e8",
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#fff", fontSize: 12, fontWeight: "600" }}>
|
||||
Install
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
export const NativeAdView = ({ children, style }: NativeAdViewProps) => (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: PLACEHOLDER_BG,
|
||||
borderWidth: 1,
|
||||
borderColor: PLACEHOLDER_BORDER,
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
position: "relative",
|
||||
},
|
||||
style as ViewStyle,
|
||||
]}
|
||||
>
|
||||
<View style={{ position: "absolute", top: 8, right: 8, zIndex: 1 }}>
|
||||
<AdLabel />
|
||||
</View>
|
||||
{children ?? <DefaultNativeAdContent />}
|
||||
</View>
|
||||
);
|
||||
|
||||
export const NativeAsset = ({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
assetType?: string;
|
||||
style?: ViewStyle | ViewStyle[];
|
||||
}) => <View style={style as ViewStyle}>{children}</View>;
|
||||
|
||||
export const NativeMediaView = ({
|
||||
style,
|
||||
}: { style?: ViewStyle | ViewStyle[] }) => (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
height: 180,
|
||||
backgroundColor: PLACEHOLDER_BORDER,
|
||||
borderRadius: 4,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
style as ViewStyle,
|
||||
]}
|
||||
>
|
||||
<Text style={{ color: PLACEHOLDER_TEXT, fontSize: 12 }}>
|
||||
Ad Media (native only)
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
export const NativeAd = {
|
||||
createForAdRequest: async (_unitId?: string, _requestOptions?: unknown) => ({
|
||||
headline: "Sample Ad Headline",
|
||||
body: "Native ads only render on a real device.",
|
||||
advertiser: "Sample Advertiser",
|
||||
callToAction: "Install",
|
||||
icon: null,
|
||||
images: [],
|
||||
starRating: null,
|
||||
store: null,
|
||||
price: null,
|
||||
addAdEventListener: () => () => {},
|
||||
removeAllListeners: () => {},
|
||||
destroy: () => {},
|
||||
}),
|
||||
};
|
||||
|
||||
const createFullScreenAdStub = () => ({
|
||||
loaded: false,
|
||||
load: () => {},
|
||||
show: () => Promise.resolve(),
|
||||
addAdEventListener: () => () => {},
|
||||
addAdEventsListener: () => () => {},
|
||||
removeAllListeners: () => {},
|
||||
});
|
||||
|
||||
export const InterstitialAd = {
|
||||
createForAdRequest: () => createFullScreenAdStub(),
|
||||
};
|
||||
|
||||
export const RewardedAd = {
|
||||
createForAdRequest: () => createFullScreenAdStub(),
|
||||
};
|
||||
|
||||
export const RewardedInterstitialAd = {
|
||||
createForAdRequest: () => createFullScreenAdStub(),
|
||||
};
|
||||
|
||||
export const AppOpenAd = {
|
||||
createForAdRequest: () => createFullScreenAdStub(),
|
||||
};
|
||||
|
||||
export const GAMInterstitialAd = {
|
||||
createForAdRequest: () => createFullScreenAdStub(),
|
||||
};
|
||||
|
||||
export const GAMRewardedAd = {
|
||||
createForAdRequest: () => createFullScreenAdStub(),
|
||||
};
|
||||
|
||||
export const GAMRewardedInterstitialAd = {
|
||||
createForAdRequest: () => createFullScreenAdStub(),
|
||||
};
|
||||
|
||||
const baseHookResult = {
|
||||
isLoaded: false,
|
||||
isOpened: false,
|
||||
isClicked: false,
|
||||
isClosed: false,
|
||||
error: null as unknown,
|
||||
load: () => {},
|
||||
show: () => {},
|
||||
};
|
||||
|
||||
export const useInterstitialAd = () => ({ ...baseHookResult });
|
||||
export const useAppOpenAd = () => ({ ...baseHookResult });
|
||||
export const useRewardedAd = () => ({
|
||||
...baseHookResult,
|
||||
isEarnedReward: false,
|
||||
reward: null,
|
||||
});
|
||||
export const useRewardedInterstitialAd = () => ({
|
||||
...baseHookResult,
|
||||
isEarnedReward: false,
|
||||
reward: null,
|
||||
});
|
||||
|
||||
export const AdsConsent = {
|
||||
requestInfoUpdate: async () => ({
|
||||
status: AdsConsentStatus.NOT_REQUIRED,
|
||||
isConsentFormAvailable: false,
|
||||
}),
|
||||
showForm: async () => ({ status: AdsConsentStatus.OBTAINED }),
|
||||
loadAndShowConsentFormIfRequired: async () => ({
|
||||
status: AdsConsentStatus.NOT_REQUIRED,
|
||||
}),
|
||||
gatherConsent: async () => ({ status: AdsConsentStatus.NOT_REQUIRED }),
|
||||
reset: () => {},
|
||||
getConsentInfo: async () => ({
|
||||
status: AdsConsentStatus.NOT_REQUIRED,
|
||||
canRequestAds: true,
|
||||
isConsentFormAvailable: false,
|
||||
privacyOptionsRequirementStatus: "NOT_REQUIRED",
|
||||
}),
|
||||
getUserChoices: async () => ({}),
|
||||
getTCString: async () => "",
|
||||
getGdprApplies: async () => false,
|
||||
getPurposeConsents: async () => "",
|
||||
getPurposeLegitimateInterests: async () => "",
|
||||
};
|
||||
|
||||
const mobileAdsInstance = {
|
||||
initialize: async () => [],
|
||||
setRequestConfiguration: async () => {},
|
||||
openAdInspector: async () => {},
|
||||
openDebugMenu: () => {},
|
||||
setAppMuted: () => {},
|
||||
setAppVolume: () => {},
|
||||
};
|
||||
|
||||
const mobileAds = () => mobileAdsInstance;
|
||||
|
||||
const defaultExport = Object.assign(mobileAds, {
|
||||
BannerAd,
|
||||
GAMBannerAd,
|
||||
BannerAdSize,
|
||||
InterstitialAd,
|
||||
RewardedAd,
|
||||
RewardedInterstitialAd,
|
||||
AppOpenAd,
|
||||
GAMInterstitialAd,
|
||||
GAMRewardedAd,
|
||||
GAMRewardedInterstitialAd,
|
||||
NativeAd,
|
||||
NativeAdView,
|
||||
NativeAsset,
|
||||
NativeMediaView,
|
||||
AdEventType,
|
||||
RewardedAdEventType,
|
||||
AdsConsent,
|
||||
AdsConsentStatus,
|
||||
AdsConsentDebugGeography,
|
||||
TestIds,
|
||||
});
|
||||
|
||||
export { mobileAds };
|
||||
export default defaultExport;
|
||||
61
apps/mobile/polyfills/web/haptics.web.ts
Normal file
61
apps/mobile/polyfills/web/haptics.web.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export enum NotificationFeedbackType {
|
||||
Success = 'success',
|
||||
Warning = 'warning',
|
||||
Error = 'error',
|
||||
}
|
||||
|
||||
export enum ImpactFeedbackStyle {
|
||||
Light = 'light',
|
||||
Medium = 'medium',
|
||||
Heavy = 'heavy',
|
||||
Soft = 'soft',
|
||||
Rigid = 'rigid',
|
||||
}
|
||||
|
||||
const vibrationPatterns: Record<
|
||||
NotificationFeedbackType | ImpactFeedbackStyle | 'selection',
|
||||
VibratePattern
|
||||
> = {
|
||||
[NotificationFeedbackType.Success]: [40, 100, 40],
|
||||
[NotificationFeedbackType.Warning]: [50, 100, 50],
|
||||
[NotificationFeedbackType.Error]: [60, 100, 60, 100, 60],
|
||||
[ImpactFeedbackStyle.Light]: [40],
|
||||
[ImpactFeedbackStyle.Medium]: [50],
|
||||
[ImpactFeedbackStyle.Heavy]: [60],
|
||||
[ImpactFeedbackStyle.Soft]: [35],
|
||||
[ImpactFeedbackStyle.Rigid]: [45],
|
||||
selection: [50],
|
||||
};
|
||||
|
||||
function isVibrationAvailable(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
'navigator' in window &&
|
||||
'vibrate' in navigator
|
||||
);
|
||||
}
|
||||
|
||||
export const selectionAsync = async (): Promise<void> => {
|
||||
if (!isVibrationAvailable()) {
|
||||
return;
|
||||
}
|
||||
navigator.vibrate(vibrationPatterns.selection);
|
||||
};
|
||||
|
||||
export const notificationAsync = async (
|
||||
type: NotificationFeedbackType = NotificationFeedbackType.Success
|
||||
): Promise<void> => {
|
||||
if (!isVibrationAvailable()) {
|
||||
return;
|
||||
}
|
||||
navigator.vibrate(vibrationPatterns[type]);
|
||||
};
|
||||
|
||||
export const impactAsync = async (
|
||||
style: ImpactFeedbackStyle = ImpactFeedbackStyle.Medium
|
||||
): Promise<void> => {
|
||||
if (!isVibrationAvailable()) {
|
||||
return;
|
||||
}
|
||||
navigator.vibrate(vibrationPatterns[style]);
|
||||
};
|
||||
203
apps/mobile/polyfills/web/imagePicker.web.ts
Normal file
203
apps/mobile/polyfills/web/imagePicker.web.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
export enum MediaTypeOptions {
|
||||
All = 'All',
|
||||
Images = 'Images',
|
||||
Videos = 'Videos',
|
||||
}
|
||||
|
||||
export enum UIImagePickerPresentationStyle {
|
||||
FULL_SCREEN = 0,
|
||||
PAGE_SHEET = 1,
|
||||
FORM_SHEET = 2,
|
||||
CURRENT_CONTEXT = 3,
|
||||
OVERFUL_SCREEN = 4,
|
||||
POPOVER = 5,
|
||||
AUTOMATIC = -2,
|
||||
}
|
||||
|
||||
interface ImagePickerAsset {
|
||||
uri: string;
|
||||
width: number;
|
||||
height: number;
|
||||
type: 'image' | 'video' | undefined;
|
||||
fileName: string | null;
|
||||
fileSize: number | undefined;
|
||||
mimeType: string | undefined;
|
||||
}
|
||||
|
||||
interface ImagePickerResult {
|
||||
canceled: boolean;
|
||||
assets: ImagePickerAsset[];
|
||||
}
|
||||
|
||||
interface ImagePickerOptions {
|
||||
mediaTypes?: MediaTypeOptions;
|
||||
allowsEditing?: boolean;
|
||||
quality?: number;
|
||||
allowsMultipleSelection?: boolean;
|
||||
base64?: boolean;
|
||||
}
|
||||
|
||||
function getAcceptString(mediaTypes?: MediaTypeOptions): string {
|
||||
switch (mediaTypes) {
|
||||
case MediaTypeOptions.Images:
|
||||
return 'image/*';
|
||||
case MediaTypeOptions.Videos:
|
||||
return 'video/*';
|
||||
default:
|
||||
return 'image/*,video/*';
|
||||
}
|
||||
}
|
||||
|
||||
function pickFileViaInput(
|
||||
accept: string,
|
||||
capture: boolean,
|
||||
multiple: boolean
|
||||
): Promise<ImagePickerResult> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = accept;
|
||||
input.multiple = multiple;
|
||||
if (capture) input.setAttribute('capture', 'environment');
|
||||
input.style.display = 'none';
|
||||
document.body.appendChild(input);
|
||||
|
||||
let resolved = false;
|
||||
const cleanup = () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
document.body.removeChild(input);
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
const files = input.files;
|
||||
if (!files || files.length === 0) {
|
||||
cleanup();
|
||||
resolve({ canceled: true, assets: [] });
|
||||
return;
|
||||
}
|
||||
const promises = Array.from(files).map(
|
||||
(file) =>
|
||||
new Promise<ImagePickerAsset>((resolveAsset) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const uri = reader.result as string;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolveAsset({
|
||||
uri,
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
type: file.type.startsWith('video') ? 'video' : 'image',
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type || undefined,
|
||||
});
|
||||
};
|
||||
img.onerror = () => {
|
||||
resolveAsset({
|
||||
uri,
|
||||
width: 0,
|
||||
height: 0,
|
||||
type: file.type.startsWith('video') ? 'video' : 'image',
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type || undefined,
|
||||
});
|
||||
};
|
||||
img.src = uri;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
})
|
||||
);
|
||||
void Promise.all(promises).then((assets) => {
|
||||
cleanup();
|
||||
resolve({ canceled: false, assets });
|
||||
});
|
||||
});
|
||||
|
||||
// Handle cancel (user closes the file dialog without selecting)
|
||||
window.addEventListener(
|
||||
'focus',
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
cleanup();
|
||||
resolve({ canceled: true, assets: [] });
|
||||
}
|
||||
}, 300);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
export async function launchImageLibraryAsync(
|
||||
options?: ImagePickerOptions
|
||||
): Promise<ImagePickerResult> {
|
||||
return pickFileViaInput(
|
||||
getAcceptString(options?.mediaTypes),
|
||||
false,
|
||||
options?.allowsMultipleSelection ?? false
|
||||
);
|
||||
}
|
||||
|
||||
export async function launchCameraAsync(
|
||||
options?: ImagePickerOptions
|
||||
): Promise<ImagePickerResult> {
|
||||
return pickFileViaInput(
|
||||
getAcceptString(options?.mediaTypes),
|
||||
true,
|
||||
options?.allowsMultipleSelection ?? false
|
||||
);
|
||||
}
|
||||
|
||||
const grantedPermission = {
|
||||
status: 'granted' as const,
|
||||
granted: true,
|
||||
canAskAgain: true,
|
||||
expires: 'never' as const,
|
||||
};
|
||||
|
||||
export async function requestMediaLibraryPermissionsAsync() {
|
||||
return grantedPermission;
|
||||
}
|
||||
|
||||
export async function getMediaLibraryPermissionsAsync() {
|
||||
return grantedPermission;
|
||||
}
|
||||
|
||||
export async function requestCameraPermissionsAsync() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return grantedPermission;
|
||||
} catch {
|
||||
return {
|
||||
status: 'denied' as const,
|
||||
granted: false,
|
||||
canAskAgain: true,
|
||||
expires: 'never' as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCameraPermissionsAsync() {
|
||||
try {
|
||||
const result = await navigator.permissions.query({
|
||||
name: 'camera' as PermissionName,
|
||||
});
|
||||
const granted = result.state === 'granted';
|
||||
return {
|
||||
status: granted ? ('granted' as const) : ('denied' as const),
|
||||
granted,
|
||||
canAskAgain: result.state !== 'denied',
|
||||
expires: 'never' as const,
|
||||
};
|
||||
} catch {
|
||||
return grantedPermission;
|
||||
}
|
||||
}
|
||||
53
apps/mobile/polyfills/web/linking.web.ts
Normal file
53
apps/mobile/polyfills/web/linking.web.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export async function openURL(url: string): Promise<true> {
|
||||
window.open(url, '_blank');
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function canOpenURL(_url: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getInitialURL(): string {
|
||||
return typeof window !== 'undefined' ? window.location.href : '';
|
||||
}
|
||||
|
||||
export function createURL(
|
||||
path: string,
|
||||
namedParameters?: { queryParams?: Record<string, string> }
|
||||
): string {
|
||||
const base = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const url = new URL(path.startsWith('/') ? path : `/${path}`, base);
|
||||
if (namedParameters?.queryParams) {
|
||||
for (const [key, value] of Object.entries(namedParameters.queryParams)) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function parse(url: string) {
|
||||
const parsed = new URL(url);
|
||||
const queryParams: Record<string, string> = {};
|
||||
parsed.searchParams.forEach((value, key) => {
|
||||
queryParams[key] = value;
|
||||
});
|
||||
return {
|
||||
path: parsed.pathname,
|
||||
queryParams,
|
||||
hostname: parsed.hostname,
|
||||
scheme: parsed.protocol.replace(':', ''),
|
||||
};
|
||||
}
|
||||
|
||||
export function addEventListener(
|
||||
_type: string,
|
||||
handler: (event: { url: string }) => void
|
||||
) {
|
||||
const listener = () => handler({ url: window.location.href });
|
||||
window.addEventListener('popstate', listener);
|
||||
return { remove: () => window.removeEventListener('popstate', listener) };
|
||||
}
|
||||
|
||||
export function useURL(): string | null {
|
||||
return typeof window !== 'undefined' ? window.location.href : null;
|
||||
}
|
||||
205
apps/mobile/polyfills/web/location.web.ts
Normal file
205
apps/mobile/polyfills/web/location.web.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { LocationGeocodedAddress } from 'expo-location';
|
||||
|
||||
type Coords = { latitude: number; longitude: number };
|
||||
|
||||
export enum LocationAccuracy {
|
||||
Lowest = 1,
|
||||
Low = 2,
|
||||
Balanced = 3,
|
||||
High = 4,
|
||||
Highest = 5,
|
||||
BestForNavigation = 6,
|
||||
}
|
||||
|
||||
export enum PermissionStatus {
|
||||
DENIED = 'denied',
|
||||
GRANTED = 'granted',
|
||||
UNDETERMINED = 'undetermined',
|
||||
}
|
||||
|
||||
interface LocationObject {
|
||||
coords: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
altitude: number | null;
|
||||
accuracy: number | null;
|
||||
altitudeAccuracy: number | null;
|
||||
heading: number | null;
|
||||
speed: number | null;
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface LocationOptions {
|
||||
accuracy?: LocationAccuracy;
|
||||
distanceInterval?: number;
|
||||
timeInterval?: number;
|
||||
}
|
||||
|
||||
interface LocationSubscription {
|
||||
remove: () => void;
|
||||
}
|
||||
|
||||
function toLocationObject(position: GeolocationPosition): LocationObject {
|
||||
return {
|
||||
coords: {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
altitude: position.coords.altitude,
|
||||
accuracy: position.coords.accuracy,
|
||||
altitudeAccuracy: position.coords.altitudeAccuracy,
|
||||
heading: position.coords.heading,
|
||||
speed: position.coords.speed,
|
||||
},
|
||||
timestamp: position.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function toPermissionResponse(state: PermissionState) {
|
||||
const granted = state === 'granted';
|
||||
return {
|
||||
status: granted
|
||||
? PermissionStatus.GRANTED
|
||||
: state === 'prompt'
|
||||
? PermissionStatus.UNDETERMINED
|
||||
: PermissionStatus.DENIED,
|
||||
granted,
|
||||
canAskAgain: state !== 'denied',
|
||||
expires: 'never' as const,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCurrentPositionAsync(
|
||||
options?: LocationOptions
|
||||
): Promise<LocationObject> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('Geolocation is not available in this browser'));
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => resolve(toLocationObject(position)),
|
||||
(error) => reject(new Error(error.message)),
|
||||
{
|
||||
enableHighAccuracy:
|
||||
options?.accuracy != null && options.accuracy >= LocationAccuracy.High,
|
||||
timeout: 10000,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function watchPositionAsync(
|
||||
options: LocationOptions | null,
|
||||
callback: (location: LocationObject) => void
|
||||
): Promise<LocationSubscription> {
|
||||
if (!navigator.geolocation) {
|
||||
throw new Error('Geolocation is not available in this browser');
|
||||
}
|
||||
const watchId = navigator.geolocation.watchPosition(
|
||||
(position) => callback(toLocationObject(position)),
|
||||
() => {},
|
||||
{
|
||||
enableHighAccuracy:
|
||||
options?.accuracy != null && options.accuracy >= LocationAccuracy.High,
|
||||
}
|
||||
);
|
||||
return { remove: () => navigator.geolocation.clearWatch(watchId) };
|
||||
}
|
||||
|
||||
export async function getLastKnownPositionAsync(): Promise<LocationObject | null> {
|
||||
try {
|
||||
return await getCurrentPositionAsync();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requestForegroundPermissionsAsync() {
|
||||
if (!navigator.geolocation) {
|
||||
return toPermissionResponse('denied');
|
||||
}
|
||||
return new Promise<ReturnType<typeof toPermissionResponse>>((resolve) => {
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
() => resolve(toPermissionResponse('granted')),
|
||||
() => resolve(toPermissionResponse('denied')),
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function requestBackgroundPermissionsAsync() {
|
||||
return toPermissionResponse('denied');
|
||||
}
|
||||
|
||||
export async function getForegroundPermissionsAsync() {
|
||||
try {
|
||||
const result = await navigator.permissions.query({
|
||||
name: 'geolocation',
|
||||
});
|
||||
return toPermissionResponse(result.state);
|
||||
} catch {
|
||||
return toPermissionResponse('prompt');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBackgroundPermissionsAsync() {
|
||||
return toPermissionResponse('denied');
|
||||
}
|
||||
|
||||
export async function geocodeAsync(
|
||||
_address: string
|
||||
): Promise<{ latitude: number; longitude: number; accuracy: number }[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function reverseGeocodeAsync({
|
||||
latitude,
|
||||
longitude,
|
||||
}: Coords): Promise<LocationGeocodedAddress[]> {
|
||||
return [
|
||||
{
|
||||
city: 'Sample City',
|
||||
street: 'Main Street',
|
||||
district: 'Downtown',
|
||||
region: 'Sample State',
|
||||
postalCode: '12345',
|
||||
country: 'Sample Country',
|
||||
isoCountryCode: 'SC',
|
||||
name: `Location at ${latitude.toFixed(4)}, ${longitude.toFixed(4)}`,
|
||||
streetNumber: '123',
|
||||
subregion: null,
|
||||
timezone: null,
|
||||
formattedAddress: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function hasServicesEnabledAsync(): Promise<boolean> {
|
||||
return typeof navigator !== 'undefined' && !!navigator.geolocation;
|
||||
}
|
||||
|
||||
export async function isBackgroundLocationAvailableAsync(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
const NativeLocation = require('expo-location/build') as Record<string, unknown>;
|
||||
|
||||
module.exports = {
|
||||
...NativeLocation,
|
||||
LocationAccuracy,
|
||||
PermissionStatus,
|
||||
getCurrentPositionAsync,
|
||||
watchPositionAsync,
|
||||
getLastKnownPositionAsync,
|
||||
requestForegroundPermissionsAsync,
|
||||
requestBackgroundPermissionsAsync,
|
||||
getForegroundPermissionsAsync,
|
||||
getBackgroundPermissionsAsync,
|
||||
geocodeAsync,
|
||||
reverseGeocodeAsync,
|
||||
hasServicesEnabledAsync,
|
||||
isBackgroundLocationAvailableAsync,
|
||||
};
|
||||
|
||||
module.exports.default = module.exports;
|
||||
51
apps/mobile/polyfills/web/maps.web.tsx
Normal file
51
apps/mobile/polyfills/web/maps.web.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import WebMapView, * as WebMaps from '@teovilla/react-native-web-maps';
|
||||
import React from 'react';
|
||||
|
||||
export const PROVIDER_GOOGLE = 'google';
|
||||
export const PROVIDER_DEFAULT = undefined;
|
||||
|
||||
const GOOGLE_MAPS_API_KEY = process.env.EXPO_PUBLIC_GOOGLE_MAPS_API_KEY;
|
||||
|
||||
const MapView = React.forwardRef((props: Record<string, unknown>, ref: React.Ref<unknown>) => {
|
||||
return (
|
||||
// @ts-expect-error — library default export is not typed as a valid JSX component
|
||||
<WebMapView
|
||||
ref={ref}
|
||||
provider={PROVIDER_GOOGLE}
|
||||
googleMapsApiKey={GOOGLE_MAPS_API_KEY}
|
||||
{...props}
|
||||
options={{
|
||||
disableDefaultUI: true,
|
||||
zoomControl: false,
|
||||
streetViewControl: false,
|
||||
mapTypeControl: false,
|
||||
fullscreenControl: false,
|
||||
rotateControl: false,
|
||||
scaleControl: false,
|
||||
keyboardShortcuts: false,
|
||||
...(props.options as Record<string, unknown>),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Object.assign(MapView, {
|
||||
...WebMaps,
|
||||
PROVIDER_GOOGLE,
|
||||
PROVIDER_DEFAULT,
|
||||
});
|
||||
|
||||
// The library namespace export doesn't declare these members but they exist at runtime
|
||||
const Maps = WebMaps as Record<string, unknown>;
|
||||
export const Marker = Maps.Marker;
|
||||
export const Callout = Maps.Callout;
|
||||
export const Polyline = Maps.Polyline;
|
||||
export const Polygon = Maps.Polygon;
|
||||
export const Circle = Maps.Circle;
|
||||
export const Overlay = Maps.Overlay;
|
||||
export const Heatmap = Maps.Heatmap;
|
||||
export const UrlTile = Maps.UrlTile;
|
||||
export const WMSTile = Maps.WMSTile;
|
||||
export const LocalTile = Maps.LocalTile;
|
||||
|
||||
export default MapView;
|
||||
80
apps/mobile/polyfills/web/notifications.web.tsx
Normal file
80
apps/mobile/polyfills/web/notifications.web.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
NotificationRequest,
|
||||
PermissionResponse,
|
||||
} from 'expo-notifications/src/Notifications.types';
|
||||
import type { NotificationHandler } from 'expo-notifications/src/NotificationsHandler';
|
||||
import { toast } from 'sonner-native';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
const { PermissionStatus } = Notifications;
|
||||
|
||||
const scheduledNotifications = new Map<
|
||||
string,
|
||||
{
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
request: NotificationRequest;
|
||||
}
|
||||
>();
|
||||
|
||||
export const setNotificationHandler = (_handler: NotificationHandler | null): void => {
|
||||
//no-op
|
||||
};
|
||||
|
||||
export const requestPermissionsAsync = async (): Promise<PermissionResponse> => {
|
||||
return {
|
||||
status: PermissionStatus.GRANTED,
|
||||
expires: 'never',
|
||||
granted: true,
|
||||
canAskAgain: true,
|
||||
};
|
||||
};
|
||||
|
||||
export const scheduleNotificationAsync = async (
|
||||
notificationRequest: NotificationRequest
|
||||
): Promise<string> => {
|
||||
const { content, trigger: _trigger } = notificationRequest;
|
||||
const { title, body } = content;
|
||||
|
||||
let message = '';
|
||||
if (title && body) {
|
||||
message = `${title}\n${body}`;
|
||||
} else if (title) {
|
||||
message = title;
|
||||
} else if (body) {
|
||||
message = `Expo Go\n${body}`;
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
|
||||
const identifier = Math.random().toString(36).substr(2, 9);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
toast(message);
|
||||
scheduledNotifications.delete(identifier);
|
||||
}, 1000);
|
||||
|
||||
scheduledNotifications.set(identifier, {
|
||||
timeoutId,
|
||||
request: notificationRequest,
|
||||
});
|
||||
|
||||
return identifier;
|
||||
};
|
||||
|
||||
export const cancelAllScheduledNotificationsAsync = async (): Promise<void> => {
|
||||
for (const { timeoutId } of scheduledNotifications.values()) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
scheduledNotifications.clear();
|
||||
};
|
||||
|
||||
export const cancelScheduledNotificationAsync = async (identifier: string): Promise<void> => {
|
||||
const scheduledNotification = scheduledNotifications.get(identifier);
|
||||
if (scheduledNotification) {
|
||||
clearTimeout(scheduledNotification.timeoutId);
|
||||
scheduledNotifications.delete(identifier);
|
||||
}
|
||||
};
|
||||
|
||||
export const getAllScheduledNotificationsAsync = async (): Promise<NotificationRequest[]> => {
|
||||
return Array.from(scheduledNotifications.values()).map(({ request }) => request);
|
||||
};
|
||||
3
apps/mobile/polyfills/web/refreshControl.web.tsx
Normal file
3
apps/mobile/polyfills/web/refreshControl.web.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { RefreshControl } from 'react-native-web-refresh-control';
|
||||
|
||||
export default RefreshControl;
|
||||
25
apps/mobile/polyfills/web/safeAreaContext.web.tsx
Normal file
25
apps/mobile/polyfills/web/safeAreaContext.web.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
export {
|
||||
SafeAreaProvider,
|
||||
SafeAreaInsetsContext,
|
||||
SafeAreaFrameContext,
|
||||
useSafeAreaFrame,
|
||||
initialWindowMetrics,
|
||||
} from 'react-native-safe-area-context/lib/commonjs';
|
||||
import { useSafeAreaInsets as useNativeSafeAreaInsets } from 'react-native-safe-area-context/lib/commonjs';
|
||||
|
||||
export { SafeAreaView } from './SafeAreaView.web';
|
||||
|
||||
export const useSafeAreaInsets = () => {
|
||||
const isTabletAndAbove =
|
||||
typeof window !== 'undefined' ? window.self !== window.top : true;
|
||||
const insets = useNativeSafeAreaInsets();
|
||||
if (isTabletAndAbove) {
|
||||
return {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 64,
|
||||
bottom: 34,
|
||||
};
|
||||
}
|
||||
return insets;
|
||||
};
|
||||
23
apps/mobile/polyfills/web/scrollview.web.tsx
Normal file
23
apps/mobile/polyfills/web/scrollview.web.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import RNScrollView from 'react-native-web/dist/exports/ScrollView';
|
||||
|
||||
export const ScrollView = React.forwardRef((props: Record<string, any>, ref: React.Ref<any>) => {
|
||||
const extendedStyle = useMemo(() => {
|
||||
if (props.horizontal) {
|
||||
return [{flexGrow: 0}, props.style]
|
||||
}
|
||||
return props.style
|
||||
}, [props.horizontal, props.style])
|
||||
|
||||
return (
|
||||
<RNScrollView
|
||||
ref={ref}
|
||||
{...props}
|
||||
style={extendedStyle}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ScrollView.displayName = 'ScrollView';
|
||||
|
||||
export default ScrollView;
|
||||
111
apps/mobile/polyfills/web/secureStore.web.ts
Normal file
111
apps/mobile/polyfills/web/secureStore.web.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
const VALUE_BYTES_LIMIT = 2048;
|
||||
|
||||
const KEYCHAIN_CONSTANTS = {
|
||||
AFTER_FIRST_UNLOCK: 0,
|
||||
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: 1,
|
||||
ALWAYS: 2,
|
||||
WHEN_PASSCODE_SET_THIS_DEVICE_ONLY: 3,
|
||||
ALWAYS_THIS_DEVICE_ONLY: 4,
|
||||
WHEN_UNLOCKED: 5,
|
||||
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 6,
|
||||
};
|
||||
|
||||
export type KeychainAccessibilityConstant = number;
|
||||
export const {
|
||||
AFTER_FIRST_UNLOCK,
|
||||
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY,
|
||||
ALWAYS,
|
||||
WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
|
||||
ALWAYS_THIS_DEVICE_ONLY,
|
||||
WHEN_UNLOCKED,
|
||||
WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
||||
} = KEYCHAIN_CONSTANTS;
|
||||
|
||||
export type SecureStoreOptions = {
|
||||
keychainService?: string;
|
||||
requireAuthentication?: boolean;
|
||||
authenticationPrompt?: string;
|
||||
keychainAccessible?: KeychainAccessibilityConstant;
|
||||
};
|
||||
|
||||
function isValidValue(value: string) {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if (new Blob([value]).size > VALUE_BYTES_LIMIT) {
|
||||
// biome-ignore lint/suspicious/noConsole: useful for debugging
|
||||
console.warn(
|
||||
`Value being stored in SecureStore is larger than ${VALUE_BYTES_LIMIT} bytes and it may not be stored successfully.`
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getStorageKey(key: string): string {
|
||||
return `_create_secure_store_${key}`;
|
||||
}
|
||||
|
||||
export async function isAvailableAsync(): Promise<boolean> {
|
||||
const testKey = '__SECURE_STORE_AVAILABILITY_TEST_KEY__';
|
||||
try {
|
||||
localStorage.setItem(testKey, 'test');
|
||||
if (localStorage.getItem(testKey) !== 'test') {
|
||||
return false;
|
||||
}
|
||||
localStorage.removeItem(testKey);
|
||||
return localStorage.getItem(testKey) === null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteItemAsync(
|
||||
key: string,
|
||||
_options: SecureStoreOptions = {}
|
||||
): Promise<void> {
|
||||
localStorage.removeItem(getStorageKey(key));
|
||||
}
|
||||
|
||||
export async function getItemAsync(
|
||||
key: string,
|
||||
_options: SecureStoreOptions = {}
|
||||
): Promise<string | null> {
|
||||
return localStorage.getItem(getStorageKey(key));
|
||||
}
|
||||
|
||||
export async function setItemAsync(
|
||||
key: string,
|
||||
value: string,
|
||||
_options: SecureStoreOptions = {}
|
||||
): Promise<void> {
|
||||
if (!isValidValue(value)) {
|
||||
throw new Error(
|
||||
'Invalid value provided to SecureStore. Values must be strings; consider JSON-encoding your values if they are serializable.'
|
||||
);
|
||||
}
|
||||
localStorage.setItem(getStorageKey(key), value);
|
||||
}
|
||||
|
||||
export function setItem(
|
||||
key: string,
|
||||
value: string,
|
||||
_options: SecureStoreOptions = {}
|
||||
): void {
|
||||
if (!isValidValue(value)) {
|
||||
throw new Error(
|
||||
'Invalid value provided to SecureStore. Values must be strings; consider JSON-encoding your values if they are serializable.'
|
||||
);
|
||||
}
|
||||
localStorage.setItem(getStorageKey(key), value);
|
||||
}
|
||||
|
||||
export function getItem(
|
||||
key: string,
|
||||
_options: SecureStoreOptions = {}
|
||||
): string | null {
|
||||
return localStorage.getItem(getStorageKey(key));
|
||||
}
|
||||
|
||||
export function canUseBiometricAuthentication(): boolean {
|
||||
return false;
|
||||
}
|
||||
72
apps/mobile/polyfills/web/statusBar.web.tsx
Normal file
72
apps/mobile/polyfills/web/statusBar.web.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { Appearance, useColorScheme } from "react-native";
|
||||
import {
|
||||
StatusBar as ExpoStatusBar,
|
||||
type StatusBarStyle,
|
||||
type StatusBarAnimation,
|
||||
type StatusBarProps,
|
||||
} from "expo-status-bar";
|
||||
import * as ExpoSB from "expo-status-bar";
|
||||
|
||||
function postColorToParent(color: string) {
|
||||
try {
|
||||
if (typeof window !== "undefined" && "parent" in window) {
|
||||
window.parent.postMessage(
|
||||
{ type: "sandbox:mobile:statusbarcolor", color, timestamp: Date.now() },
|
||||
"*"
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
console.warn("Color was not sent to parent");
|
||||
}
|
||||
}
|
||||
|
||||
function styleToBarColor(
|
||||
style: StatusBarStyle | "auto" | "inverted" = "auto",
|
||||
colorScheme = Appearance.getColorScheme()
|
||||
) {
|
||||
const actual = colorScheme ?? "light";
|
||||
let resolved:
|
||||
| Exclude<StatusBarStyle, "auto" | "inverted">
|
||||
| "light"
|
||||
| "dark" = style as any;
|
||||
|
||||
if (style === "auto") resolved = actual === "light" ? "dark" : "light";
|
||||
else if (style === "inverted")
|
||||
resolved = actual === "light" ? "light" : "dark";
|
||||
|
||||
return resolved === "light" ? "#FFFFFF" : "#000000";
|
||||
}
|
||||
|
||||
export const StatusBar = React.forwardRef<any, StatusBarProps>(
|
||||
function StatusBar({ style = "auto", ...props }, _ref) {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
postColorToParent(styleToBarColor(style, colorScheme));
|
||||
}, [style, colorScheme]);
|
||||
|
||||
return <ExpoStatusBar style={style} {...props} />;
|
||||
}
|
||||
);
|
||||
|
||||
export const setStatusBarStyle = (style: StatusBarStyle, animated?: boolean) =>
|
||||
ExpoSB.setStatusBarStyle(style, animated);
|
||||
|
||||
export const setStatusBarHidden = (
|
||||
hidden: boolean,
|
||||
animation?: StatusBarAnimation
|
||||
) => ExpoSB.setStatusBarHidden(hidden, animation);
|
||||
|
||||
export const setStatusBarBackgroundColor = (
|
||||
backgroundColor: string,
|
||||
animated?: boolean
|
||||
) => ExpoSB.setStatusBarBackgroundColor(backgroundColor as any, animated);
|
||||
|
||||
export const setStatusBarNetworkActivityIndicatorVisible = (visible: boolean) =>
|
||||
ExpoSB.setStatusBarNetworkActivityIndicatorVisible(visible);
|
||||
|
||||
export const setStatusBarTranslucent = (translucent: boolean) =>
|
||||
ExpoSB.setStatusBarTranslucent(translucent);
|
||||
|
||||
export type { StatusBarStyle, StatusBarAnimation, StatusBarProps };
|
||||
24
apps/mobile/polyfills/web/tabbar.web.tsx
Normal file
24
apps/mobile/polyfills/web/tabbar.web.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Tabs as ExpoTabs } from 'expo-router/build/layouts/Tabs';
|
||||
import { merge } from 'lodash';
|
||||
import { forwardRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
export const BASE_TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 49 : 56;
|
||||
|
||||
export const Tabs = forwardRef((props: any, ref: any) => {
|
||||
const isInIframe = typeof window !== 'undefined' ? window.self !== window.top : false;
|
||||
const height = props.screenOptions.tabBarStyle?.height || (BASE_TAB_BAR_HEIGHT + (isInIframe ? 34 : 0));
|
||||
|
||||
return (
|
||||
<ExpoTabs
|
||||
{...props}
|
||||
screenOptions={merge(props.screenOptions, {
|
||||
tabBarStyle: merge(props.screenOptions.tabBarStyle, { height }),
|
||||
})}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
(Tabs as any).Screen = ExpoTabs.Screen;
|
||||
|
||||
export default Tabs;
|
||||
56
apps/mobile/polyfills/web/webBrowser.web.ts
Normal file
56
apps/mobile/polyfills/web/webBrowser.web.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export enum WebBrowserResultType {
|
||||
CANCEL = 'cancel',
|
||||
DISMISS = 'dismiss',
|
||||
OPENED = 'opened',
|
||||
LOCKED = 'locked',
|
||||
}
|
||||
|
||||
interface WebBrowserResult {
|
||||
type: WebBrowserResultType;
|
||||
}
|
||||
|
||||
let _openWindow: Window | null = null;
|
||||
|
||||
export async function openBrowserAsync(
|
||||
url: string,
|
||||
_options?: {
|
||||
toolbarColor?: string;
|
||||
controlsColor?: string;
|
||||
secondaryToolbarColor?: string;
|
||||
enableBarCollapsing?: boolean;
|
||||
showTitle?: boolean;
|
||||
enableDefaultShareMenuItem?: boolean;
|
||||
windowName?: string;
|
||||
windowFeatures?: string;
|
||||
}
|
||||
): Promise<WebBrowserResult> {
|
||||
_openWindow = window.open(url, '_blank');
|
||||
return { type: WebBrowserResultType.OPENED };
|
||||
}
|
||||
|
||||
export async function openAuthSessionAsync(
|
||||
url: string,
|
||||
_redirectUrl?: string,
|
||||
_options?: { showInRecents?: boolean }
|
||||
): Promise<WebBrowserResult & { url?: string }> {
|
||||
const authWindow = window.open(url, '_blank');
|
||||
if (!authWindow) {
|
||||
return { type: WebBrowserResultType.CANCEL };
|
||||
}
|
||||
return { type: WebBrowserResultType.OPENED };
|
||||
}
|
||||
|
||||
export function dismissBrowser(): void {
|
||||
if (_openWindow && !_openWindow.closed) {
|
||||
_openWindow.close();
|
||||
_openWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function warmUpAsync(): Promise<void> {}
|
||||
export async function coolDownAsync(): Promise<void> {}
|
||||
export async function mayInitWithUrlAsync(
|
||||
_url: string
|
||||
): Promise<{ servicePackage: string | null }> {
|
||||
return { servicePackage: null };
|
||||
}
|
||||
109
apps/mobile/polyfills/web/webview.web.tsx
Normal file
109
apps/mobile/polyfills/web/webview.web.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import type { StyleProp, ViewStyle } from 'react-native';
|
||||
|
||||
type Props = {
|
||||
source: { uri?: string; html?: string; headers?: Record<string, string> };
|
||||
style?: StyleProp<ViewStyle>;
|
||||
injectedJavaScript?: string;
|
||||
onMessage?: (ev: { nativeEvent: { data: string } }) => void;
|
||||
onLoadStart?: () => void;
|
||||
onLoad?: () => void;
|
||||
onLoadEnd?: () => void;
|
||||
onError?: (syntheticEvent: {
|
||||
nativeEvent: { code: number; description: string };
|
||||
}) => void;
|
||||
onNavigationStateChange?: (navState: {
|
||||
url: string;
|
||||
loading: boolean;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
}) => void;
|
||||
onShouldStartLoadWithRequest?: (event: { url: string }) => boolean;
|
||||
scrollEnabled?: boolean;
|
||||
bounces?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Web-based implementation of React Native WebView using iframe
|
||||
*/
|
||||
export const WebView = forwardRef((props: Props, ref) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const {
|
||||
source,
|
||||
style,
|
||||
injectedJavaScript,
|
||||
onMessage,
|
||||
onLoadStart,
|
||||
onLoad,
|
||||
onLoadEnd,
|
||||
onNavigationStateChange,
|
||||
} = props;
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
onMessage?.({ nativeEvent: { data: event.data } });
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [onMessage]);
|
||||
|
||||
// Imperative handle to match RN WebView API
|
||||
useImperativeHandle(ref, () => ({
|
||||
injectJavaScript: (js: string) => {
|
||||
iframeRef.current?.contentWindow?.postMessage(js, '*');
|
||||
},
|
||||
goBack: () => {
|
||||
iframeRef.current?.contentWindow?.history.back();
|
||||
},
|
||||
goForward: () => {
|
||||
iframeRef.current?.contentWindow?.history.forward();
|
||||
},
|
||||
reload: () => {
|
||||
iframeRef.current?.contentWindow?.location.reload();
|
||||
},
|
||||
stopLoading: () => {
|
||||
// Not directly possible with iframe
|
||||
},
|
||||
}));
|
||||
|
||||
const src = source.html
|
||||
? `data:text/html;charset=utf-8,${encodeURIComponent(source.html)}`
|
||||
: source.uri;
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
style={{
|
||||
border: 'none',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: props.scrollEnabled === false ? 'hidden' : 'auto',
|
||||
...(style as Record<string, unknown>),
|
||||
}}
|
||||
allow="third-party-cookies"
|
||||
onLoad={(e) => {
|
||||
onLoadStart?.();
|
||||
onLoad?.();
|
||||
onLoadEnd?.();
|
||||
if (injectedJavaScript) {
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
injectedJavaScript,
|
||||
'*'
|
||||
);
|
||||
}
|
||||
const win = e.currentTarget.contentWindow;
|
||||
if (win) {
|
||||
onNavigationStateChange?.({
|
||||
url: win.location.href,
|
||||
loading: false,
|
||||
canGoBack: win.history.length > 1,
|
||||
canGoForward: win.history.length > 1,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default WebView;
|
||||
Reference in New Issue
Block a user