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 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
); );
} }
}; };
+99 -91
View File
@@ -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 () => {
const initDeepLinkHandling = async () => { try {
const initialUrl = await Linking.getInitialURL(); const initialUrl = await Linking.getInitialURL();
if (initialUrl) { if (initialUrl) {
// console.log("Initial URL:", initialUrl); // Handle initial URL once
handleDeepLink({ url: initialUrl }); handleDeepLink({ url: initialUrl });
} }
} catch (e) {}
Linking.addEventListener("url", handleDeepLink); // Subscribe to future app-open deep links
const sub = Linking.addEventListener("url", handleDeepLink);
return () => { return () => {
Linking.removeEventListener("url", handleDeepLink); try {
}; sub?.remove?.();
}; } catch (e) {
// Fallback for older expo-linking versions
initDeepLinkHandling(); Linking.removeEventListener?.("url", handleDeepLink);
}, []);
useEffect(() => {
if (tempTaskData && currentUID && isFullyLoaded) {
// console.log("try to navigate to task");
navigateToTask(tempTaskData);
setTempTaskData(null);
} }
}, [tempTaskData, currentUID, isFullyLoaded]); };
};
let cleanup;
init().then((c) => {
cleanup = c;
});
return () => cleanup?.();
}, []);
useEffect(() => { useEffect(() => {
if (!pendingMusicId || !isFullyLoaded) { if (!pendingNavigation || !isFullyLoaded) return;
if (pendingNavigation.type === "task") {
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;
}
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 },
}); });
setPendingMusicId(null); return;
}, [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) { if (resourceKey === "music" || resourceKey === "musics") {
try { setPendingNavigation({
setPendingMusicId(decodeURIComponent(musicIdentifier)); type: "music",
} catch (_) { params: { projectId: identifier, ...queryParams },
setPendingMusicId(musicIdentifier); });
} return;
}
} }
if (isWeb) { if (resourceKey === "playback" || resourceKey === "playbacks") {
setTimeout(cleanURL, 1500); setPendingNavigation({
} else { type: "playback",
cleanURL(); 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
View File
@@ -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
View File
@@ -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",