Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: improve linking #129

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { usePathname } from "expo-router";
import { UserInactivityProvider } from "~/context/UserInactivity";
import { PortalHost } from '@rn-primitives/portal';
import { isBiometricSupported } from "~/lib/isBiometricSupported";
import { useHandleLinking } from "~/hooks/useHandleLinking";

const LIGHT_THEME: Theme = {
dark: false,
Expand Down Expand Up @@ -54,6 +55,8 @@ export default function RootLayout() {
const [checkedOnboarding, setCheckedOnboarding] = React.useState(false);
const isUnlocked = useAppStore((store) => store.unlocked);
const pathname = usePathname();

useHandleLinking();
useConnectionChecker();

const rootNavigationState = useRootNavigationState();
Expand Down
1 change: 0 additions & 1 deletion components/FocusableCamera.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export function FocusableCamera({ onScanned }: FocusableCameraProps) {
const handleBarCodeScanned = ({ data }: BarcodeScanningResult) => {
onScanned(data);
};

return (
<CameraView
onBarcodeScanned={handleBarCodeScanned}
Expand Down
35 changes: 18 additions & 17 deletions components/QRCodeScanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,27 @@ import { Camera } from "expo-camera";
import { Text } from "~/components/ui/text";
import { CameraOff } from "./Icons";

type QRCodeScannerProps = {
interface QRCodeScannerProps {
onScanned: (data: string) => void;
};
startScanning: boolean;
}

function QRCodeScanner({ onScanned }: QRCodeScannerProps) {
const [isScanning, setScanning] = React.useState(false);
function QRCodeScanner({ onScanned, startScanning = true }: QRCodeScannerProps) {
const [isScanning, setScanning] = React.useState(startScanning);
const [isLoading, setLoading] = React.useState(false);
const [permissionStatus, setPermissionStatus] = React.useState(PermissionStatus.UNDETERMINED);

useEffect(() => {
// Add some timeout to allow the screen transition to finish before
// starting the camera to avoid stutters
setLoading(true);
window.setTimeout(async () => {
await scan();
setLoading(false);
}, 200);
}, []);
if (startScanning) {
setLoading(true);
window.setTimeout(async () => {
await scan();
setLoading(false);
}, 200);
}
}, [startScanning]);

async function scan() {
const { status } = await Camera.requestCameraPermissionsAsync();
Expand All @@ -44,10 +47,10 @@ function QRCodeScanner({ onScanned }: QRCodeScannerProps) {
};

return (
<>
{isLoading && (
<View className="flex-1">
{(isLoading || (!isScanning && permissionStatus === PermissionStatus.UNDETERMINED)) && (
<View className="flex-1 justify-center items-center">
<Loading />
<Loading className="text-primary-foreground" />
</View>
)}
{!isLoading && <>
Expand All @@ -59,13 +62,11 @@ function QRCodeScanner({ onScanned }: QRCodeScannerProps) {
</View>
}
{isScanning && (
<>
<FocusableCamera onScanned={handleScanned} />
</>
<FocusableCamera onScanned={handleScanned} />
)}
</>
}
</>
</View>
);
}

Expand Down
29 changes: 29 additions & 0 deletions components/Receiver.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import React from "react";
import { View } from "react-native";
import { Text } from "~/components/ui/text";

interface ReceiverProps {
originalText: string;
invoice?: string;
}

export function Receiver({ originalText, invoice }: ReceiverProps) {
const shouldShowReceiver =
originalText !== invoice &&
originalText.toLowerCase().replace("lightning:", "").includes("@");

if (!shouldShowReceiver) {
return null;
}

return (
<View className="flex flex-col gap-2">
<Text className="text-muted-foreground text-center font-semibold2">
To
</Text>
<Text className="text-center text-foreground text-2xl font-medium2">
{originalText.toLowerCase().replace("lightning:", "")}
</Text>
</View>
);
}
87 changes: 65 additions & 22 deletions hooks/useHandleLinking.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,81 @@
import * as Linking from "expo-linking";
import { getInitialURL } from "expo-linking";
import { router, useRootNavigationState } from "expo-router";
import React from "react";
import { useEffect, useCallback, useRef } from "react";

const SUPPORTED_SCHEMES = ["lightning:", "bitcoin:", "alby:"];
// TESTING: ["lightning:", "bitcoin:", "alby:", "exp:"]
const SUPPORTED_SCHEMES = ["lightning", "bitcoin", "alby", "exp"];

export function useHandleLinking() {
const rootNavigationState = useRootNavigationState();
let url = Linking.useURL();
let hasNavigationState = !!rootNavigationState?.key;
const navigationState = useRootNavigationState();
const pendingLinkRef = useRef<string | null>(null);

React.useEffect(() => {
if (!hasNavigationState) {
return;
}
console.log("Received linking URL", url);
const handleLink = useCallback(
(url: string) => {
if (!url) return;

const { hostname, path, queryParams, scheme } = Linking.parse(url);

if (!scheme) return;

for (const scheme of SUPPORTED_SCHEMES) {
if (url?.startsWith(scheme)) {
console.log("Linking URL matched scheme", url, scheme);
if (url.startsWith(scheme + "//")) {
url = url.replace(scheme + "//", scheme);
if (SUPPORTED_SCHEMES.indexOf(scheme) > -1) {
let fullUrl = scheme === "exp" ? path : `${scheme}:${hostname}`;

// Add query parameters to the URL if they exist
if (queryParams && Object.keys(queryParams).length > 0) {
const queryString = new URLSearchParams(
queryParams as Record<string, string>,
).toString();
fullUrl += `?${queryString}`;
}

// TODO: it should not always navigate to send,
// but that's the only linking functionality supported right now
router.dismissAll();
router.navigate({
if (router.canDismiss()) {
router.dismissAll();
}
router.push({
pathname: "/send",
params: {
url,
url: fullUrl,
},
});
break;
return;
}

// Redirect the user to the home screen
// if no match was found
router.replace({
pathname: "/",
});
},
[navigationState?.key],
);

useEffect(() => {
const processInitialURL = async () => {
const url = await getInitialURL();
if (url) pendingLinkRef.current = url;
};

processInitialURL();

const subscription = Linking.addEventListener(
"url",
(event: { url: string }) => {
if (navigationState?.key) {
handleLink(event.url);
} else {
pendingLinkRef.current = event.url;
}
},
);

return () => subscription.remove();
}, [handleLink]);

useEffect(() => {
if (navigationState?.key && pendingLinkRef.current) {
handleLink(pendingLinkRef.current);
pendingLinkRef.current = null;
}
}, [url, hasNavigationState]);
}, [navigationState?.key, handleLink]);
}
13 changes: 9 additions & 4 deletions pages/Wildcard.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { Stack, usePathname } from "expo-router";
import { router, Stack, useFocusEffect, usePathname } from "expo-router";
import { View } from "react-native";
import Loading from "~/components/Loading";
import { Text } from "~/components/ui/text";
import { useHandleLinking } from "~/hooks/useHandleLinking";

export function Wildcard() {
const pathname = usePathname();
useHandleLinking();

// Should a user ever land on this page, redirect them to home
useFocusEffect(() => {
router.replace({
pathname: "/"
});
});

return (
<View className="flex-1 justify-center items-center flex flex-col gap-3">
Expand All @@ -17,7 +22,7 @@ export function Wildcard() {
}}
/>
<Loading />
<Text>Loading {pathname}</Text>
<Text>Loading</Text>
</View>
);
}
19 changes: 2 additions & 17 deletions pages/send/ConfirmPayment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import React from "react";
import { View } from "react-native";
import { ZapIcon } from "~/components/Icons";
import Loading from "~/components/Loading";
import { Receiver } from "~/components/Receiver";
import Screen from "~/components/Screen";
import { Button } from "~/components/ui/button";
import { Text } from "~/components/ui/text";
Expand Down Expand Up @@ -94,23 +95,7 @@ export function ConfirmPayment() {
</View>
)
)}
{
/* only show "To" for lightning addresses */ originalText !==
invoice &&
originalText
.toLowerCase()
.replace("lightning:", "")
.includes("@") && (
<View className="flex flex-col gap-2">
<Text className="text-muted-foreground text-center font-semibold2">
To
</Text>
<Text className="text-center text-foreground text-2xl font-medium2">
{originalText.toLowerCase().replace("lightning:", "")}
</Text>
</View>
)
}
<Receiver originalText={originalText} invoice={invoice} />
</View>
<View className="p-6">
<Button
Expand Down
31 changes: 11 additions & 20 deletions pages/send/LNURLPay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { errorToast } from "~/lib/errorToast";
import Loading from "~/components/Loading";
import { DualCurrencyInput } from "~/components/DualCurrencyInput";
import DismissableKeyboardView from "~/components/DismissableKeyboardView";
import { Receiver } from "~/components/Receiver";

export function LNURLPay() {
const { lnurlDetailsJSON, originalText } =
Expand Down Expand Up @@ -65,29 +66,19 @@ export function LNURLPay() {
readOnly={isAmountReadOnly}
autoFocus={!isAmountReadOnly}
/>
{lnurlDetails.commentAllowed &&
<View className="w-full">
<Text className="text-muted-foreground text-center font-semibold2">
Comment
</Text>
<Input
className="w-full border-transparent bg-transparent text-center native:text-2xl font-semibold2"
placeholder="Enter an optional comment"
value={comment}
onChangeText={setComment}
returnKeyType="done"
maxLength={lnurlDetails.commentAllowed}
/>
</View>
}
<View>
<View className="w-full">
<Text className="text-muted-foreground text-center font-semibold2">
To
</Text>
<Text className="text-center text-foreground text-2xl font-medium2">
{originalText}
Comment
</Text>
<Input
className="w-full border-transparent bg-transparent text-center native:text-2xl font-semibold2"
placeholder="Enter an optional comment"
value={comment}
onChangeText={setComment}
returnKeyType="done"
/>
</View>
<Receiver originalText={originalText} />
</View>
<View className="p-6">
<Button
Expand Down
12 changes: 2 additions & 10 deletions pages/send/PaymentSuccess.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Button } from "~/components/ui/button";
import { Text } from "~/components/ui/text";
import Screen from "~/components/Screen";
import { useGetFiatAmount } from "~/hooks/useGetFiatAmount";
import { Receiver } from "~/components/Receiver";

export function PaymentSuccess() {
const getFiatAmount = useGetFiatAmount();
Expand All @@ -30,16 +31,7 @@ export function PaymentSuccess() {
<Text className="text-2xl text-muted-foreground font-semibold2">{getFiatAmount(+amount)}</Text>
}
</View>
{originalText !== invoice &&
<View>
<Text className="text-muted-foreground text-center font-semibold2">
Sent to
</Text>
<Text className="text-foreground text-center text-2xl font-medium2">
{originalText}
</Text>
</View>
}
<Receiver originalText={originalText} invoice={invoice} />
</View>
<View className="p-6">
<Button
Expand Down
Loading
Loading