From 91818e58ec63d074e27264f9dddf8df63ca04a2b Mon Sep 17 00:00:00 2001 From: leon-morival Date: Wed, 5 Nov 2025 09:56:26 +0100 Subject: [PATCH] feat: universal link provider --- src/config/firebase.js | 4 +- src/providers/UniversalLinkProvider.js | 198 +++++++++++++------------ src/utils/shareSheet.js | 58 ++++++-- vercel.json | 2 +- 4 files changed, 152 insertions(+), 110 deletions(-) diff --git a/src/config/firebase.js b/src/config/firebase.js index 6ef21d7..56ce243 100644 --- a/src/config/firebase.js +++ b/src/config/firebase.js @@ -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 ); } }; diff --git a/src/providers/UniversalLinkProvider.js b/src/providers/UniversalLinkProvider.js index 98f7d43..78882f6 100644 --- a/src/providers/UniversalLinkProvider.js +++ b/src/providers/UniversalLinkProvider.js @@ -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; diff --git a/src/utils/shareSheet.js b/src/utils/shareSheet.js index 92e39c8..487ebb5 100644 --- a/src/utils/shareSheet.js +++ b/src/utils/shareSheet.js @@ -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, diff --git a/vercel.json b/vercel.json index f732ca8..8fe7994 100644 --- a/vercel.json +++ b/vercel.json @@ -6,7 +6,7 @@ ], "headers": [ { - "source": "/.well-known/apple-app-site-association", + "source": "/.web/well-known/apple-app-site-association", "headers": [ { "key": "Content-Type",