feat: universal link provider
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { Platform } from "react-native";
|
||||
import {
|
||||
getReactNativePersistence,
|
||||
initializeAuth,
|
||||
@@ -9,6 +8,7 @@ import "firebase/compat/auth";
|
||||
import "firebase/compat/firestore";
|
||||
import "firebase/compat/functions";
|
||||
import "firebase/compat/storage";
|
||||
import { Platform } from "react-native";
|
||||
|
||||
const functionsInstances = {};
|
||||
const emulatorConfigured = {};
|
||||
@@ -25,7 +25,7 @@ const configureFunctionsEmulator = (instance, regionKey = "us-central1") => {
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[firebase] Unable to set functions emulator for region ${regionKey}`,
|
||||
error?.message,
|
||||
error?.message
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
import { useContext, useState, useEffect } from "reactn";
|
||||
import * as Linking from "expo-linking";
|
||||
import { UserDataContext } from "./UserDataProvider";
|
||||
import { useContext, useEffect, useRef, useState } from "reactn";
|
||||
import { appleAppStoreUrl } from "../data";
|
||||
import { isWeb } from "../hooks/useLayoutType";
|
||||
import { Routes } from "../navigation";
|
||||
import { navigate, navigateToTask } from "../navigation/NavigationService";
|
||||
import { Routes } from "../navigation/Routes";
|
||||
import { SplashAnimationContext } from "./SplashAnimationProvider";
|
||||
const appJson = require("../../app.json");
|
||||
import { UserDataContext } from "./UserDataProvider";
|
||||
|
||||
export const storeURL = {
|
||||
apple: "apps.apple.com/app/id1661696886",
|
||||
google: "play.google.com/store/apps",
|
||||
const APP_SCHEME = "musicland";
|
||||
const APP_PATH_PREFIX = "app";
|
||||
const APP_STORE_FALLBACK_URL = appleAppStoreUrl;
|
||||
|
||||
const normalizePath = (rawPath = "") => {
|
||||
if (!rawPath) return "";
|
||||
const trimmed = rawPath.replace(/^\//, "");
|
||||
if (trimmed.startsWith(`${APP_PATH_PREFIX}/`)) {
|
||||
return trimmed.slice(APP_PATH_PREFIX.length + 1);
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const buildAppSchemeUrl = (rawPath, queryParams = {}) => {
|
||||
const path = normalizePath(rawPath);
|
||||
const fullPath = path ? `${APP_PATH_PREFIX}/${path}` : APP_PATH_PREFIX;
|
||||
const queryString = new URLSearchParams(queryParams ?? {}).toString();
|
||||
return `${APP_SCHEME}://${fullPath}${queryString ? `?${queryString}` : ""}`;
|
||||
};
|
||||
|
||||
const UniversalLinkProvider = ({ children }) => {
|
||||
const { currentUID } = useContext(UserDataContext);
|
||||
const { isFullyLoaded } = useContext(SplashAnimationContext);
|
||||
|
||||
const [tempTaskData, setTempTaskData] = useState(null);
|
||||
const [pendingMusicId, setPendingMusicId] = useState(null);
|
||||
const [pendingNavigation, setPendingNavigation] = useState(null);
|
||||
const lastUrlRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDeepLink = async (event) => {
|
||||
// console.log("event", event);
|
||||
const incomingUrl = event?.url || "";
|
||||
if (!incomingUrl) return;
|
||||
|
||||
const { path = "", queryParams } = Linking.parse(event.url);
|
||||
// Prevent handling the exact same URL multiple times
|
||||
if (lastUrlRef.current === incomingUrl) return;
|
||||
lastUrlRef.current = incomingUrl;
|
||||
|
||||
const { path = "", queryParams = {} } = Linking.parse(incomingUrl) || {};
|
||||
// console.log("path", path);
|
||||
// console.log("queryParams", queryParams);
|
||||
|
||||
const appSchemeURL = `${
|
||||
appJson.expo.scheme
|
||||
}://app/${path}?${new URLSearchParams(queryParams).toString()}`;
|
||||
|
||||
const appStoreURL = `itms-apps://${storeURL.apple}`;
|
||||
const appSchemeURL = buildAppSchemeUrl(path, queryParams);
|
||||
|
||||
if (isWeb) {
|
||||
const userAgent =
|
||||
@@ -42,14 +56,13 @@ const UniversalLinkProvider = ({ children }) => {
|
||||
(/Macintosh/.test(userAgent) &&
|
||||
navigator.maxTouchPoints &&
|
||||
navigator.maxTouchPoints > 2);
|
||||
|
||||
if (isIOSBrowser) {
|
||||
try {
|
||||
const canOpen = await Linking.canOpenURL(appSchemeURL);
|
||||
if (canOpen) {
|
||||
window.location.href = appSchemeURL;
|
||||
} else {
|
||||
window.location.href = appStoreURL;
|
||||
window.location.href = APP_STORE_FALLBACK_URL;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Redirection error:", error);
|
||||
@@ -62,97 +75,92 @@ const UniversalLinkProvider = ({ children }) => {
|
||||
handleParams(path, queryParams);
|
||||
}
|
||||
};
|
||||
const init = async () => {
|
||||
try {
|
||||
const initialUrl = await Linking.getInitialURL();
|
||||
if (initialUrl) {
|
||||
// Handle initial URL once
|
||||
handleDeepLink({ url: initialUrl });
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const initDeepLinkHandling = async () => {
|
||||
const initialUrl = await Linking.getInitialURL();
|
||||
if (initialUrl) {
|
||||
// console.log("Initial URL:", initialUrl);
|
||||
handleDeepLink({ url: initialUrl });
|
||||
}
|
||||
|
||||
Linking.addEventListener("url", handleDeepLink);
|
||||
|
||||
// Subscribe to future app-open deep links
|
||||
const sub = Linking.addEventListener("url", handleDeepLink);
|
||||
return () => {
|
||||
Linking.removeEventListener("url", handleDeepLink);
|
||||
try {
|
||||
sub?.remove?.();
|
||||
} catch (e) {
|
||||
// Fallback for older expo-linking versions
|
||||
Linking.removeEventListener?.("url", handleDeepLink);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
initDeepLinkHandling();
|
||||
let cleanup;
|
||||
init().then((c) => {
|
||||
cleanup = c;
|
||||
});
|
||||
return () => cleanup?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (tempTaskData && currentUID && isFullyLoaded) {
|
||||
// console.log("try to navigate to task");
|
||||
navigateToTask(tempTaskData);
|
||||
setTempTaskData(null);
|
||||
}
|
||||
}, [tempTaskData, currentUID, isFullyLoaded]);
|
||||
if (!pendingNavigation || !isFullyLoaded) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingMusicId || !isFullyLoaded) {
|
||||
if (pendingNavigation.type === "task") {
|
||||
if (!currentUID) return;
|
||||
navigateToTask(pendingNavigation.params);
|
||||
setPendingNavigation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(Routes.MusicDetails, {
|
||||
projectId: pendingMusicId,
|
||||
autoPlay: true,
|
||||
action: "share",
|
||||
});
|
||||
setPendingMusicId(null);
|
||||
}, [isFullyLoaded, pendingMusicId]);
|
||||
|
||||
const handleParams = (path, _queryParams = {}) => {
|
||||
if ((typeof path === "string" && path.length > 0) || _queryParams?.music) {
|
||||
const normalized = typeof path === "string" ? path.replace(/^\/+/, "") : "";
|
||||
const segments = normalized
|
||||
.split("/")
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const musicIndex = segments.findIndex(
|
||||
(segment) => segment?.toLowerCase() === "music"
|
||||
);
|
||||
|
||||
let musicIdentifier = null;
|
||||
if (musicIndex !== -1 && segments[musicIndex + 1]) {
|
||||
musicIdentifier = segments[musicIndex + 1];
|
||||
} else if (segments.length === 1 && segments[0]) {
|
||||
musicIdentifier = segments[0];
|
||||
} else if (typeof _queryParams?.music === "string") {
|
||||
musicIdentifier = _queryParams.music;
|
||||
} else if (typeof _queryParams?.projectId === "string") {
|
||||
musicIdentifier = _queryParams.projectId;
|
||||
}
|
||||
|
||||
if (musicIdentifier) {
|
||||
try {
|
||||
setPendingMusicId(decodeURIComponent(musicIdentifier));
|
||||
} catch (_) {
|
||||
setPendingMusicId(musicIdentifier);
|
||||
}
|
||||
}
|
||||
if (pendingNavigation.type === "music") {
|
||||
navigate(Routes.MusicDetails, pendingNavigation.params);
|
||||
setPendingNavigation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWeb) {
|
||||
setTimeout(cleanURL, 1500);
|
||||
} else {
|
||||
cleanURL();
|
||||
if (pendingNavigation.type === "playback") {
|
||||
navigate(Routes.Playbacks, pendingNavigation.params);
|
||||
setPendingNavigation(null);
|
||||
}
|
||||
}, [pendingNavigation, currentUID, isFullyLoaded]);
|
||||
const handleParams = (path, queryParams = {}) => {
|
||||
const normalizedPath = normalizePath(path);
|
||||
if (!normalizedPath) return;
|
||||
|
||||
const [resource, identifier] = normalizedPath.split("/").filter(Boolean);
|
||||
if (!resource || !identifier) return;
|
||||
|
||||
const resourceKey = resource.toLowerCase();
|
||||
|
||||
if (resourceKey === "tasks" || resourceKey === "task") {
|
||||
setPendingNavigation({
|
||||
type: "task",
|
||||
params: { taskID: identifier, ...queryParams },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (resourceKey === "music" || resourceKey === "musics") {
|
||||
setPendingNavigation({
|
||||
type: "music",
|
||||
params: { projectId: identifier, ...queryParams },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (resourceKey === "playback" || resourceKey === "playbacks") {
|
||||
setPendingNavigation({
|
||||
type: "playback",
|
||||
params: {
|
||||
projectId: identifier,
|
||||
playbackId: identifier,
|
||||
focusId: identifier,
|
||||
...queryParams,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const cleanURL = () => {
|
||||
if (isWeb) {
|
||||
const url = new URL(window.location);
|
||||
|
||||
// console.log(url);
|
||||
|
||||
url.search = "";
|
||||
const next = `${url.origin}${url.pathname}${url.hash || ""}`;
|
||||
window.history.replaceState({}, document.title, next);
|
||||
}
|
||||
};
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default UniversalLinkProvider;
|
||||
|
||||
+46
-12
@@ -9,17 +9,7 @@ import {
|
||||
|
||||
const APP_SCHEME = appJson?.expo?.scheme || "musicland";
|
||||
const APP_SCHEME_BASE = `${APP_SCHEME}://app`;
|
||||
|
||||
const defaultPayload = {
|
||||
heading: musiclandShareHeading,
|
||||
url: musiclandShareUrl,
|
||||
shareTitle: "MusicLand",
|
||||
shareMessage: musiclandShareMessage,
|
||||
qrUrl: musiclandShareUrl,
|
||||
copyUrl: musiclandShareUrl,
|
||||
linkLabel: musiclandShareUrl,
|
||||
appLink: APP_SCHEME_BASE,
|
||||
};
|
||||
const QR_REDIRECT_PATH = "link";
|
||||
|
||||
const trimTrailingSlash = (value) =>
|
||||
typeof value === "string" ? value.replace(/\/+$/, "") : "";
|
||||
@@ -70,6 +60,46 @@ const resolveBaseShareUrl = () => {
|
||||
return musiclandShareUrl;
|
||||
};
|
||||
|
||||
const createShareQrUrl = ({ sharePath = "", fallbackUrl } = {}) => {
|
||||
const sanitizedPath = trimLeadingSlash(sharePath);
|
||||
const schemeDeepLink = createSchemeDeepLink(sanitizedPath);
|
||||
const baseUrl = trimTrailingSlash(resolveBaseShareUrl());
|
||||
|
||||
const resolvedFallback =
|
||||
fallbackUrl ||
|
||||
(baseUrl ? (sanitizedPath ? `${baseUrl}/${sanitizedPath}` : baseUrl) : "");
|
||||
|
||||
if (!baseUrl) {
|
||||
return schemeDeepLink;
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectUrl = new URL(`${baseUrl}/${QR_REDIRECT_PATH}`);
|
||||
redirectUrl.searchParams.set("scheme", schemeDeepLink);
|
||||
if (resolvedFallback) {
|
||||
redirectUrl.searchParams.set("fallback", resolvedFallback);
|
||||
}
|
||||
return redirectUrl.toString();
|
||||
} catch (error) {
|
||||
console.error("share.qr.url.create.error", error);
|
||||
return schemeDeepLink;
|
||||
}
|
||||
};
|
||||
|
||||
const defaultPayload = {
|
||||
heading: musiclandShareHeading,
|
||||
url: musiclandShareUrl,
|
||||
shareTitle: "MusicLand",
|
||||
shareMessage: musiclandShareMessage,
|
||||
qrUrl: createShareQrUrl({
|
||||
sharePath: "",
|
||||
fallbackUrl: musiclandShareUrl,
|
||||
}),
|
||||
copyUrl: musiclandShareUrl,
|
||||
linkLabel: musiclandShareUrl,
|
||||
appLink: APP_SCHEME_BASE,
|
||||
};
|
||||
|
||||
export const createMusicSharePayload = ({ projectId, title, artist } = {}) => {
|
||||
if (!projectId) return null;
|
||||
|
||||
@@ -79,6 +109,10 @@ export const createMusicSharePayload = ({ projectId, title, artist } = {}) => {
|
||||
const sharePath = `music/${encodedProjectId}`;
|
||||
const shareUrl = `${baseUrl}/${sharePath}`;
|
||||
const schemeDeepLink = createSchemeDeepLink(sharePath);
|
||||
const qrUrl = createShareQrUrl({
|
||||
sharePath,
|
||||
fallbackUrl: shareUrl,
|
||||
});
|
||||
|
||||
let shareMessage = musiclandShareMessage;
|
||||
if (title) {
|
||||
@@ -94,7 +128,7 @@ export const createMusicSharePayload = ({ projectId, title, artist } = {}) => {
|
||||
shareTitle: title ? `${title} - MusicLand` : "MusicLand",
|
||||
shareMessage,
|
||||
url: shareUrl,
|
||||
qrUrl: shareUrl,
|
||||
qrUrl,
|
||||
copyUrl: shareUrl,
|
||||
linkLabel: shareUrl,
|
||||
appLink: schemeDeepLink,
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
],
|
||||
"headers": [
|
||||
{
|
||||
"source": "/.well-known/apple-app-site-association",
|
||||
"source": "/.web/well-known/apple-app-site-association",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
|
||||
Reference in New Issue
Block a user