feat: universal link provider
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import { Platform } from "react-native";
|
|
||||||
import {
|
import {
|
||||||
getReactNativePersistence,
|
getReactNativePersistence,
|
||||||
initializeAuth,
|
initializeAuth,
|
||||||
@@ -9,6 +8,7 @@ import "firebase/compat/auth";
|
|||||||
import "firebase/compat/firestore";
|
import "firebase/compat/firestore";
|
||||||
import "firebase/compat/functions";
|
import "firebase/compat/functions";
|
||||||
import "firebase/compat/storage";
|
import "firebase/compat/storage";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
const functionsInstances = {};
|
const functionsInstances = {};
|
||||||
const emulatorConfigured = {};
|
const emulatorConfigured = {};
|
||||||
@@ -25,7 +25,7 @@ const configureFunctionsEmulator = (instance, regionKey = "us-central1") => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[firebase] Unable to set functions emulator for region ${regionKey}`,
|
`[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 * 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 { isWeb } from "../hooks/useLayoutType";
|
||||||
|
import { Routes } from "../navigation";
|
||||||
import { navigate, navigateToTask } from "../navigation/NavigationService";
|
import { navigate, navigateToTask } from "../navigation/NavigationService";
|
||||||
import { Routes } from "../navigation/Routes";
|
|
||||||
import { SplashAnimationContext } from "./SplashAnimationProvider";
|
import { SplashAnimationContext } from "./SplashAnimationProvider";
|
||||||
const appJson = require("../../app.json");
|
import { UserDataContext } from "./UserDataProvider";
|
||||||
|
|
||||||
export const storeURL = {
|
const APP_SCHEME = "musicland";
|
||||||
apple: "apps.apple.com/app/id1661696886",
|
const APP_PATH_PREFIX = "app";
|
||||||
google: "play.google.com/store/apps",
|
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 UniversalLinkProvider = ({ children }) => {
|
||||||
const { currentUID } = useContext(UserDataContext);
|
const { currentUID } = useContext(UserDataContext);
|
||||||
const { isFullyLoaded } = useContext(SplashAnimationContext);
|
const { isFullyLoaded } = useContext(SplashAnimationContext);
|
||||||
|
const [pendingNavigation, setPendingNavigation] = useState(null);
|
||||||
const [tempTaskData, setTempTaskData] = useState(null);
|
const lastUrlRef = useRef(null);
|
||||||
const [pendingMusicId, setPendingMusicId] = useState(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleDeepLink = async (event) => {
|
const handleDeepLink = async (event) => {
|
||||||
// console.log("event", 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("path", path);
|
||||||
// console.log("queryParams", queryParams);
|
// console.log("queryParams", queryParams);
|
||||||
|
const appSchemeURL = buildAppSchemeUrl(path, queryParams);
|
||||||
const appSchemeURL = `${
|
|
||||||
appJson.expo.scheme
|
|
||||||
}://app/${path}?${new URLSearchParams(queryParams).toString()}`;
|
|
||||||
|
|
||||||
const appStoreURL = `itms-apps://${storeURL.apple}`;
|
|
||||||
|
|
||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
const userAgent =
|
const userAgent =
|
||||||
@@ -42,14 +56,13 @@ const UniversalLinkProvider = ({ children }) => {
|
|||||||
(/Macintosh/.test(userAgent) &&
|
(/Macintosh/.test(userAgent) &&
|
||||||
navigator.maxTouchPoints &&
|
navigator.maxTouchPoints &&
|
||||||
navigator.maxTouchPoints > 2);
|
navigator.maxTouchPoints > 2);
|
||||||
|
|
||||||
if (isIOSBrowser) {
|
if (isIOSBrowser) {
|
||||||
try {
|
try {
|
||||||
const canOpen = await Linking.canOpenURL(appSchemeURL);
|
const canOpen = await Linking.canOpenURL(appSchemeURL);
|
||||||
if (canOpen) {
|
if (canOpen) {
|
||||||
window.location.href = appSchemeURL;
|
window.location.href = appSchemeURL;
|
||||||
} else {
|
} else {
|
||||||
window.location.href = appStoreURL;
|
window.location.href = APP_STORE_FALLBACK_URL;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Redirection error:", error);
|
console.error("Redirection error:", error);
|
||||||
@@ -62,97 +75,92 @@ const UniversalLinkProvider = ({ children }) => {
|
|||||||
handleParams(path, queryParams);
|
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 () => {
|
// Subscribe to future app-open deep links
|
||||||
const initialUrl = await Linking.getInitialURL();
|
const sub = Linking.addEventListener("url", handleDeepLink);
|
||||||
if (initialUrl) {
|
|
||||||
// console.log("Initial URL:", initialUrl);
|
|
||||||
handleDeepLink({ url: initialUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
Linking.addEventListener("url", handleDeepLink);
|
|
||||||
|
|
||||||
return () => {
|
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(() => {
|
useEffect(() => {
|
||||||
if (tempTaskData && currentUID && isFullyLoaded) {
|
if (!pendingNavigation || !isFullyLoaded) return;
|
||||||
// console.log("try to navigate to task");
|
|
||||||
navigateToTask(tempTaskData);
|
|
||||||
setTempTaskData(null);
|
|
||||||
}
|
|
||||||
}, [tempTaskData, currentUID, isFullyLoaded]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (pendingNavigation.type === "task") {
|
||||||
if (!pendingMusicId || !isFullyLoaded) {
|
if (!currentUID) return;
|
||||||
|
navigateToTask(pendingNavigation.params);
|
||||||
|
setPendingNavigation(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
navigate(Routes.MusicDetails, {
|
if (pendingNavigation.type === "music") {
|
||||||
projectId: pendingMusicId,
|
navigate(Routes.MusicDetails, pendingNavigation.params);
|
||||||
autoPlay: true,
|
setPendingNavigation(null);
|
||||||
action: "share",
|
return;
|
||||||
});
|
|
||||||
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 (isWeb) {
|
if (pendingNavigation.type === "playback") {
|
||||||
setTimeout(cleanURL, 1500);
|
navigate(Routes.Playbacks, pendingNavigation.params);
|
||||||
} else {
|
setPendingNavigation(null);
|
||||||
cleanURL();
|
}
|
||||||
|
}, [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;
|
return children;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UniversalLinkProvider;
|
export default UniversalLinkProvider;
|
||||||
|
|||||||
+46
-12
@@ -9,17 +9,7 @@ import {
|
|||||||
|
|
||||||
const APP_SCHEME = appJson?.expo?.scheme || "musicland";
|
const APP_SCHEME = appJson?.expo?.scheme || "musicland";
|
||||||
const APP_SCHEME_BASE = `${APP_SCHEME}://app`;
|
const APP_SCHEME_BASE = `${APP_SCHEME}://app`;
|
||||||
|
const QR_REDIRECT_PATH = "link";
|
||||||
const defaultPayload = {
|
|
||||||
heading: musiclandShareHeading,
|
|
||||||
url: musiclandShareUrl,
|
|
||||||
shareTitle: "MusicLand",
|
|
||||||
shareMessage: musiclandShareMessage,
|
|
||||||
qrUrl: musiclandShareUrl,
|
|
||||||
copyUrl: musiclandShareUrl,
|
|
||||||
linkLabel: musiclandShareUrl,
|
|
||||||
appLink: APP_SCHEME_BASE,
|
|
||||||
};
|
|
||||||
|
|
||||||
const trimTrailingSlash = (value) =>
|
const trimTrailingSlash = (value) =>
|
||||||
typeof value === "string" ? value.replace(/\/+$/, "") : "";
|
typeof value === "string" ? value.replace(/\/+$/, "") : "";
|
||||||
@@ -70,6 +60,46 @@ const resolveBaseShareUrl = () => {
|
|||||||
return musiclandShareUrl;
|
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 } = {}) => {
|
export const createMusicSharePayload = ({ projectId, title, artist } = {}) => {
|
||||||
if (!projectId) return null;
|
if (!projectId) return null;
|
||||||
|
|
||||||
@@ -79,6 +109,10 @@ export const createMusicSharePayload = ({ projectId, title, artist } = {}) => {
|
|||||||
const sharePath = `music/${encodedProjectId}`;
|
const sharePath = `music/${encodedProjectId}`;
|
||||||
const shareUrl = `${baseUrl}/${sharePath}`;
|
const shareUrl = `${baseUrl}/${sharePath}`;
|
||||||
const schemeDeepLink = createSchemeDeepLink(sharePath);
|
const schemeDeepLink = createSchemeDeepLink(sharePath);
|
||||||
|
const qrUrl = createShareQrUrl({
|
||||||
|
sharePath,
|
||||||
|
fallbackUrl: shareUrl,
|
||||||
|
});
|
||||||
|
|
||||||
let shareMessage = musiclandShareMessage;
|
let shareMessage = musiclandShareMessage;
|
||||||
if (title) {
|
if (title) {
|
||||||
@@ -94,7 +128,7 @@ export const createMusicSharePayload = ({ projectId, title, artist } = {}) => {
|
|||||||
shareTitle: title ? `${title} - MusicLand` : "MusicLand",
|
shareTitle: title ? `${title} - MusicLand` : "MusicLand",
|
||||||
shareMessage,
|
shareMessage,
|
||||||
url: shareUrl,
|
url: shareUrl,
|
||||||
qrUrl: shareUrl,
|
qrUrl,
|
||||||
copyUrl: shareUrl,
|
copyUrl: shareUrl,
|
||||||
linkLabel: shareUrl,
|
linkLabel: shareUrl,
|
||||||
appLink: schemeDeepLink,
|
appLink: schemeDeepLink,
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
],
|
],
|
||||||
"headers": [
|
"headers": [
|
||||||
{
|
{
|
||||||
"source": "/.well-known/apple-app-site-association",
|
"source": "/.web/well-known/apple-app-site-association",
|
||||||
"headers": [
|
"headers": [
|
||||||
{
|
{
|
||||||
"key": "Content-Type",
|
"key": "Content-Type",
|
||||||
|
|||||||
Reference in New Issue
Block a user