feat: universal link provider

This commit is contained in:
2025-11-05 09:56:26 +01:00
parent ffaccd71a7
commit 91818e58ec
4 changed files with 152 additions and 110 deletions
+2 -2
View File
@@ -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
);
}
};
+103 -95
View File
@@ -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
View File
@@ -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,