diff --git a/src/components/bottomsheets/CommentsBottomSheet.js b/src/components/bottomsheets/CommentsBottomSheet.js index 4377a1c..c1eaca8 100644 --- a/src/components/bottomsheets/CommentsBottomSheet.js +++ b/src/components/bottomsheets/CommentsBottomSheet.js @@ -30,6 +30,7 @@ import { getArtistDisplayName } from '../../utils/artistName'; import { Palette } from '../../styles'; import { FONT_FAMILY } from '../../styles/Fonts'; import ProfilePicture from '../ProfilePicture'; +import { ensureAuthenticated } from '../../utils/authRedirect'; let externalOpen; let externalClose; @@ -72,6 +73,16 @@ const CommentsBottomSheet = () => { const [text, setText] = useState(''); const [typing, setTyping] = useState(false); + const requireAuth = useCallback(() => { + return ensureAuthenticated(currentUID, { + onIntercept: () => { + try { + modalRef.current?.dismiss?.(); + } catch (_error) {} + }, + }); + }, [currentUID]); + useEffect(() => { externalOpen = (pid) => { setProjectId(pid || null); @@ -127,7 +138,8 @@ const CommentsBottomSheet = () => { const onSend = async () => { const value = (text || '').trim(); - if (!value || !projectId || !currentUID) return; + if (!value || !projectId) return; + if (!requireAuth()) return; try { setText(''); const docRef = await projectsRef @@ -354,11 +366,23 @@ const CommentsBottomSheet = () => { placeholderTextColor='#FFFFFFAA' value={text} onChangeText={(t) => { + if (!currentUID && !requireAuth()) { + return; + } setText(t); }} onFocus={() => { + if (!requireAuth()) { + setTyping(false); + return; + } setTyping(true); }} + onPressIn={() => { + if (!requireAuth()) { + setTyping(false); + } + }} onBlur={() => setTyping(false)} style={{ flex: 1, diff --git a/src/components/player/GlobalAudioPlayer.js b/src/components/player/GlobalAudioPlayer.js index 852f4eb..ab5545b 100644 --- a/src/components/player/GlobalAudioPlayer.js +++ b/src/components/player/GlobalAudioPlayer.js @@ -12,6 +12,7 @@ import useNavigateToMusicDetails from "../../hooks/useNavigateToMusicDetails"; import { useUser } from "../../providers/UserDataProvider"; import { gutters, Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; +import { ensureAuthenticated } from "../../utils/authRedirect"; const formatDuration = (ms) => { const totalSeconds = Math.max(0, Math.floor((ms || 0) / 1000)); @@ -82,22 +83,34 @@ const GlobalAudioPlayer = () => { } }, [pendingLike, likedFromDoc]); - const handleToggleLike = useCallback(async (event) => { - event?.stopPropagation?.(); - if (!projectId || !currentUID || isLikeProcessing) return; - const next = !effectiveIsLiked; - setPendingLike(next); - try { - await projectsRef.doc(projectId).set( - { - likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID), - }, - { merge: true } - ); - } catch (_error) { - setPendingLike(null); - } - }, [currentUID, effectiveIsLiked, isLikeProcessing, projectId]); + const handleToggleLike = useCallback( + async (event) => { + event?.stopPropagation?.(); + if (!projectId || isLikeProcessing) return; + if ( + !ensureAuthenticated(currentUID, { + onIntercept: () => { + setPendingLike(null); + }, + }) + ) { + return; + } + const next = !effectiveIsLiked; + setPendingLike(next); + try { + await projectsRef.doc(projectId).set( + { + likedBy: next ? arrayUnion(currentUID) : arrayRemove(currentUID), + }, + { merge: true } + ); + } catch (_error) { + setPendingLike(null); + } + }, + [currentUID, effectiveIsLiked, isLikeProcessing, projectId] + ); const insets = useSafeAreaInsets(); @@ -199,11 +212,11 @@ const GlobalAudioPlayer = () => { 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/navigation/BottomTab.js b/src/navigation/BottomTab.js index b6be09a..39774db 100644 --- a/src/navigation/BottomTab.js +++ b/src/navigation/BottomTab.js @@ -1,6 +1,7 @@ import { Motion } from "@legendapp/motion"; import { createBottomTabNavigator } from "@react-navigation/bottom-tabs"; import React from "reactn"; +import { useGlobal } from "reactn"; import { Palette } from "../styles"; import { Routes } from "./Routes"; import { tabs } from "../assets"; @@ -15,6 +16,9 @@ import HomeStack from "./HomeStack"; const BottomTab = createBottomTabNavigator(); export const BottomTabScreen = () => { + const [currentUID] = useGlobal("currentUID"); + const isAuthenticated = !!currentUID; + const renderIcon = (icon, focused) => ( { headerShown: false, tabBarShowLabel: true, tabBarIcon: ({ focused }) => renderIcon(tabs.albums, focused), + hide: !isAuthenticated, }} /> { headerShown: false, tabBarShowLabel: true, tabBarIcon: ({ focused }) => renderIcon(tabs.person, focused), + hide: !isAuthenticated, }} /> diff --git a/src/navigation/TabBar.js b/src/navigation/TabBar.js index b7d2fe6..9c497c2 100644 --- a/src/navigation/TabBar.js +++ b/src/navigation/TabBar.js @@ -19,6 +19,13 @@ const TabBar = ({ state = {}, descriptors = {}, navigation = {} }) => { : isIOS ? Math.max(bottomInset, fallbackBottomOffset) : bottomInset + fallbackBottomOffset; + const visibleRoutesCount = Math.max( + 1, + (state?.routes || []).reduce((count, route) => { + const descriptor = descriptors[route.key]; + return descriptor?.options?.hide ? count : count + 1; + }, 0) + ); return ( { key={route.key} style={{ alignSelf: "center", - width: `${100 / state?.routes?.length}%`, + width: `${100 / visibleRoutesCount}%`, height: "100%", ...Style.containerCenter, }} diff --git a/src/providers/UniversalLinkProvider.js b/src/providers/UniversalLinkProvider.js index 98f7d43..76fb21d 100644 --- a/src/providers/UniversalLinkProvider.js +++ b/src/providers/UniversalLinkProvider.js @@ -1,38 +1,138 @@ -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 [pendingNavigation, setPendingNavigation] = useState(null); + const lastUrlRef = useRef(null); - const [tempTaskData, setTempTaskData] = useState(null); - const [pendingMusicId, setPendingMusicId] = useState(null); + const handleLinkRedirect = async (queryParams = {}) => { + const schemeParam = queryParams?.scheme; + const fallbackParam = queryParams?.fallback; + + const scheme = + typeof schemeParam === "string" && schemeParam.trim().length > 0 + ? schemeParam.trim() + : null; + const fallback = + typeof fallbackParam === "string" && fallbackParam.trim().length > 0 + ? fallbackParam.trim() + : null; + + if (isWeb) { + if (scheme) { + let fallbackTimer = null; + if (fallback) { + fallbackTimer = window.setTimeout(() => { + try { + window.location.href = fallback; + } catch (error) { + console.error("link.redirect.fallback.error", error); + } + }, 1500); + } + + try { + window.location.href = scheme; + } catch (error) { + console.error("link.redirect.scheme.error", error); + if (fallbackTimer) window.clearTimeout(fallbackTimer); + if (fallback) { + try { + window.location.href = fallback; + } catch (fallbackError) { + console.error("link.redirect.fallback.error", fallbackError); + } + } + } + return; + } + + if (fallback) { + try { + window.location.href = fallback; + } catch (error) { + console.error("link.redirect.fallback.error", error); + } + } + return; + } + + if (!scheme && fallback) { + try { + await Linking.openURL(fallback); + } catch (error) { + console.error("link.redirect.native.fallback.error", error); + } + return; + } + + if (!scheme) return; + + try { + const canOpen = await Linking.canOpenURL(scheme); + if (canOpen) { + await Linking.openURL(scheme); + } else if (fallback) { + await Linking.openURL(fallback); + } else { + await Linking.openURL(APP_STORE_FALLBACK_URL); + } + } catch (error) { + console.error("link.redirect.native.scheme.error", error); + try { + if (fallback) { + await Linking.openURL(fallback); + } else { + await Linking.openURL(APP_STORE_FALLBACK_URL); + } + } catch (fallbackError) { + console.error("link.redirect.native.fallback.error", fallbackError); + } + } + }; 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 +142,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 +161,97 @@ 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; + + if (normalizedPath.toLowerCase() === "link") { + handleLinkRedirect(queryParams); + 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/providers/UserDataProvider.js b/src/providers/UserDataProvider.js index be7e7c3..f6a2a77 100644 --- a/src/providers/UserDataProvider.js +++ b/src/providers/UserDataProvider.js @@ -15,6 +15,7 @@ import firebase, { } from "../config/firebase"; import { getUserPreferredArtistName } from "../utils/artistName"; import { getLikeFieldPath, LIKE_TARGET } from "../utils/likes"; +import { ensureAuthenticated } from "../utils/authRedirect"; const SONG_LIKES_FIELD = getLikeFieldPath(LIKE_TARGET.SONG); const PLAYBACK_LIKES_FIELD = getLikeFieldPath(LIKE_TARGET.PLAYBACK); @@ -191,6 +192,9 @@ export default ({ children }) => { }; const createNewProject = async ({ hasLyrics = false } = {}) => { + if (!ensureAuthenticated(currentUID)) { + return null; + } const existingProjectsCount = Array.isArray(userProjects) ? userProjects.length : 0; @@ -225,7 +229,10 @@ export default ({ children }) => { }; const followUser = async (userId) => { try { - if (currentUID && userId) { + if (!ensureAuthenticated(currentUID)) { + return; + } + if (userId) { await usersRef.doc(userId).set( { followedBy: arrayUnion(currentUID), @@ -243,7 +250,10 @@ export default ({ children }) => { const unfollowUser = async (userId) => { try { - if (currentUID && userId) { + if (!ensureAuthenticated(currentUID)) { + return; + } + if (userId) { await usersRef.doc(userId).set( { followedBy: arrayRemove(currentUID), diff --git a/src/screens/Home/Home.js b/src/screens/Home/Home.js index 87573ee..1d64815 100644 --- a/src/screens/Home/Home.js +++ b/src/screens/Home/Home.js @@ -21,6 +21,7 @@ import { projectsRef, usersRef, videosRef } from "../../config/firebase"; import useDataFromRef from "../../hooks/useDataFromRef"; import { isWeb } from "../../hooks/useLayoutType.js"; import Page from "../../layouts/Page"; +import LandingPage from "../LandingPage"; import { navigate } from "../../navigation/NavigationService"; import { Routes } from "../../navigation/Routes"; import { useUser } from "../../providers/UserDataProvider"; @@ -54,6 +55,10 @@ const Home = ({ navigation, route }) => { } = useUser(); const { setTooltip } = useMinuit(); + if (!currentUID) { + return ; + } + const projects = useMemo( () => (Array.isArray(userProjects) ? userProjects : []), [userProjects] diff --git a/src/screens/Library/MusicDetails.js b/src/screens/Library/MusicDetails.js index 2b026a6..d73d6b7 100644 --- a/src/screens/Library/MusicDetails.js +++ b/src/screens/Library/MusicDetails.js @@ -1,16 +1,20 @@ import { Feather } from "@expo/vector-icons"; -import useTrackController from "../../hooks/useTrackController"; -import usePlayer from "../../hooks/usePlayer"; import { Image as ExpoImage } from "expo-image"; -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { + Linking, Pressable, Image as RNImage, ScrollView, StyleSheet, Text, View, - Linking, } from "react-native"; import { SheetManager } from "react-native-actions-sheet"; import { @@ -29,21 +33,24 @@ import { usersRef, } from "../../config/firebase"; import useDataFromRef from "../../hooks/useDataFromRef"; +import usePlayer from "../../hooks/usePlayer"; +import useTrackController from "../../hooks/useTrackController"; import Page from "../../layouts/Page"; import { Palette } from "../../styles"; import { FONT_FAMILY } from "../../styles/Fonts"; import Style, { gutters, size } from "../../styles/Style"; import { getArtistDisplayName } from "../../utils/artistName"; +import { ensureAuthenticated } from "../../utils/authRedirect"; +import { + createMusicSharePayload, + openShareSheet, +} from "../../utils/shareSheet"; import { formatStructureLabel, getPromptLabelForStructure, getSegmentMeta, normalizeStructureType, } from "../../utils/songStructure"; -import { - createMusicSharePayload, - openShareSheet, -} from "../../utils/shareSheet"; // 20 secondes const timeBeforeIncrement = 20000; @@ -413,13 +420,7 @@ const MusicDetails = ({ route }) => { console.log("MusicDetails seekBy error", e?.message); } }, - [ - trackDescriptor, - positionMs, - isCurrentTrack, - ensureLoaded, - seekTrackBy, - ] + [trackDescriptor, positionMs, isCurrentTrack, ensureLoaded, seekTrackBy] ); const handleLyricsSeek = useCallback( @@ -495,7 +496,8 @@ const MusicDetails = ({ route }) => { .replace(/\s+/g, " ") .trim(); const isSentenceEnd = (txt) => /[.!?]$/.test((txt || "").trim()); - const stripSectionTag = (txt) => String(txt || "").replace(SECTION_TAG_REGEX, ""); + const stripSectionTag = (txt) => + String(txt || "").replace(SECTION_TAG_REGEX, ""); const parseSectionTag = (txt) => { const match = String(txt || "").match(SECTION_TAG_REGEX); if (!match) return null; @@ -503,7 +505,8 @@ const MusicDetails = ({ route }) => { const lower = label.toLowerCase(); let type = "section"; if (lower.includes("refrain") || lower.includes("chorus")) type = "refrain"; - else if (lower.includes("couplet") || lower.includes("verse")) type = "couplet"; + else if (lower.includes("couplet") || lower.includes("verse")) + type = "couplet"; else if ( lower.includes("pré") || lower.includes("prechorus") || @@ -632,7 +635,9 @@ const MusicDetails = ({ route }) => { current = null; return; } - const lines = groupAlignedWordsToLines(current.words, { removeTags: true }); + const lines = groupAlignedWordsToLines(current.words, { + removeTags: true, + }); if (!lines.length) { current = null; return; @@ -785,7 +790,10 @@ const MusicDetails = ({ route }) => { justifyContent: "center", opacity: isCurrentTrack ? 1 : 0.4, }} - contentStyle={{ alignItems: "center", justifyContent: "center" }} + contentStyle={{ + alignItems: "center", + justifyContent: "center", + }} > { { - if (!projectId || !currentUID) return; + if (!projectId) return; + if (!ensureAuthenticated(currentUID)) return; const next = !fav; setFav(next); try { diff --git a/src/screens/Library/MusicDetails.web.js b/src/screens/Library/MusicDetails.web.js index dc77215..2cad411 100644 --- a/src/screens/Library/MusicDetails.web.js +++ b/src/screens/Library/MusicDetails.web.js @@ -32,6 +32,7 @@ import { projectsRef, usersRef, } from "../../config/firebase"; +import { ensureAuthenticated } from "../../utils/authRedirect"; import useDataFromRef from "../../hooks/useDataFromRef"; import useTrackController from "../../hooks/useTrackController"; import usePlayer from "../../hooks/usePlayer"; @@ -1001,7 +1002,8 @@ const MusicDetails = ({ route }) => { { - if (!projectId || !currentUID) return; + if (!projectId) return; + if (!ensureAuthenticated(currentUID)) return; const next = !fav; setFav(next); try { diff --git a/src/screens/Library/components/MusicCard.js b/src/screens/Library/components/MusicCard.js index b1fe313..ccc9555 100644 --- a/src/screens/Library/components/MusicCard.js +++ b/src/screens/Library/components/MusicCard.js @@ -14,6 +14,7 @@ import { useGlobal } from "reactn"; import { icons, img } from "../../../assets"; import PressableScale from "../../../components/PressableScale"; import { LIKE_TARGET, toggleProjectLike } from "../../../utils/likes"; +import { ensureAuthenticated } from "../../../utils/authRedirect"; import { Palette, Style } from "../../../styles"; import { FONT_FAMILY } from "../../../styles/Fonts"; import { size } from "../../../styles/Style"; @@ -55,7 +56,8 @@ const MusicCard = ({ }, [JSON.stringify(likedBy), currentUID]); const toggleLike = async () => { - if (!projectId || !currentUID) return; + if (!projectId) return; + if (!ensureAuthenticated(currentUID)) return; const next = !selected; setSelected(next); try { diff --git a/src/screens/Playbacks/Playbacks.js b/src/screens/Playbacks/Playbacks.js index 0fb01b8..2ee192b 100644 --- a/src/screens/Playbacks/Playbacks.js +++ b/src/screens/Playbacks/Playbacks.js @@ -8,7 +8,7 @@ import React, { useRef, useState, } from "react"; -import { Image, Platform, Pressable, Share, Text, View } from "react-native"; +import { Image, Platform, Pressable, Text, View } from "react-native"; import Carousel from "react-native-reanimated-carousel"; import { responsiveHeight } from "react-native-responsive-dimensions"; import { icons, img } from "../../assets"; @@ -28,6 +28,11 @@ import { LIKE_TARGET, toggleProjectLike, } from "../../utils/likes"; +import { ensureAuthenticated } from "../../utils/authRedirect"; +import { + createPlaybackSharePayload, + openShareSheet, +} from "../../utils/shareSheet"; import { SheetManager } from "react-native-actions-sheet"; import { Feather } from "@expo/vector-icons"; @@ -109,7 +114,13 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { const projectTitle = typeof item?.title === "string" ? item.title.trim() : ""; - const creatorName = item?.userName; + const creatorName = useMemo(() => { + if (owner?.displayName) return owner.displayName; + if (owner?.artistName) return owner.artistName; + if (owner?.userName) return owner.userName; + if (typeof item?.userName === "string") return item.userName; + return ""; + }, [item?.userName, owner?.artistName, owner?.displayName, owner?.userName]); const videoPlayer = useVideoPlayer(videoUrl || null, (p) => { p.loop = false; @@ -182,7 +193,23 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { return () => global.clearInterval(id); }, [isActive, videoPlayer]); - // partage: géré via l'API native Share directement dans l'UI + const sharePayload = useMemo(() => { + if (!item?.id) return null; + return createPlaybackSharePayload({ + projectId: item.id, + title: projectTitle || undefined, + artist: creatorName || undefined, + playbackUrl: videoUrl || undefined, + }); + }, [creatorName, item?.id, projectTitle, videoUrl]); + + const handleShare = useCallback(() => { + if (sharePayload) { + openShareSheet(sharePayload); + } + }, [sharePayload]); + + // partage: via feuille unifiée (prend en charge QR) return ( { imageProps={{ priority: "high" }} /> - {owner?.id && currentUID && owner.id !== currentUID && ( + {owner?.id && owner.id !== currentUID && ( { if (isFollowActionPending) return; + if ( + !ensureAuthenticated(currentUID, { + onIntercept: () => { + setIsFollowActionPending(false); + }, + }) + ) { + return; + } try { setIsFollowActionPending(true); @@ -301,7 +337,10 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { { try { - if (!currentUID || !item?.id) return; + if (!item?.id) return; + if (!ensureAuthenticated(currentUID)) { + return; + } const nextLiked = !isLiked; setIsLiked(nextLiked); setLikesCount((c) => Math.max(0, c + (nextLiked ? 1 : -1))); @@ -358,25 +397,7 @@ const PlaybackItem = ({ item, isActive, userCache, getUserByUid }) => { )} - { - try { - const url = item?.playbackUrl || ""; - const title = item?.title || "Partager"; - const base = item?.title - ? `Découvre « ${item.title} » sur MusicLand` - : `Découvre ce playback sur MusicLand`; - const message = url ? `${base}\n${url}` : base; - - await Share.share( - Platform.select({ - ios: url ? { url, message, title } : { message, title }, - default: { message, title }, - }) - ); - } catch (e) {} - }} - > + diff --git a/src/screens/Playbacks/components/CommentsPanel.web.js b/src/screens/Playbacks/components/CommentsPanel.web.js index dab9209..18204d7 100644 --- a/src/screens/Playbacks/components/CommentsPanel.web.js +++ b/src/screens/Playbacks/components/CommentsPanel.web.js @@ -31,6 +31,7 @@ import { Palette } from "../../../styles"; import { FONT_FAMILY } from "../../../styles/Fonts"; import { size } from "../../../styles/Style"; import { getArtistDisplayName } from "../../../utils/artistName"; +import { ensureAuthenticated } from "../../../utils/authRedirect"; moment.locale("fr"); @@ -58,6 +59,10 @@ const CommentsPanel = ({ ); const [text, setText] = useState(""); const scrollRef = useRef(null); + const requireAuth = useCallback( + () => ensureAuthenticated(currentUID), + [currentUID] + ); useEffect(() => { setText(""); @@ -111,7 +116,8 @@ const CommentsPanel = ({ const onSend = useCallback(async () => { const value = (text || "").trim(); - if (!value || !projectId || !currentUID) return; + if (!value || !projectId) return; + if (!requireAuth()) return; try { setText(""); const docRef = await projectsRef @@ -153,6 +159,7 @@ const CommentsPanel = ({ currentUserData, onCommentAdded, projectId, + requireAuth, setComments, text, ]); @@ -226,23 +233,42 @@ const CommentsPanel = ({ { + if (!requireAuth()) { + return; + } + setText(value); + }} placeholder={ canComment ? "Commente" : "Connecte-toi pour laisser un commentaire" } placeholderTextColor="#FFFFFF90" - editable={canComment} + editable + onFocus={() => { + if (!requireAuth()) { + try { + inputRef.current?.blur?.(); + } catch (_e) {} + } + }} + onPressIn={() => { + if (!requireAuth()) { + try { + inputRef.current?.blur?.(); + } catch (_e) {} + } + }} style={[styles.commentInput, !canComment && { opacity: 0.6 }]} /> [ styles.sendButton, - (!canComment || !text.trim()) && styles.sendButtonDisabled, - pressed && canComment && styles.sendButtonPressed, + !text.trim() && styles.sendButtonDisabled, + pressed && text.trim() && styles.sendButtonPressed, ]} > { + if (owner?.displayName) return owner.displayName; + if (owner?.artistName) return owner.artistName; + if (owner?.userName) return owner.userName; + if (typeof item?.userName === "string") return item.userName; + return ""; + }, [item?.userName, owner?.artistName, owner?.displayName, owner?.userName]); + + const sharePayload = useMemo(() => { + if (!item?.id) return null; + return createPlaybackSharePayload({ + projectId: item.id, + title: typeof item?.title === "string" ? item.title.trim() : undefined, + artist: creatorName || undefined, + playbackUrl: videoUrl || undefined, + }); + }, [creatorName, item?.id, item?.title, videoUrl]); + + const handleShare = useCallback(() => { + if (sharePayload) { + openShareSheet(sharePayload); + } + }, [sharePayload]); + const handleReport = useCallback(() => { if (!item?.id) return; SheetManager.show("Report", { @@ -400,10 +428,19 @@ const PlaybackItem = ({ /> )} - {owner?.id && currentUID && owner.id !== currentUID && ( + {owner?.id && owner.id !== currentUID && ( { + if ( + !ensureAuthenticated(currentUID, { + onIntercept: () => { + setIsFollowing(false); + }, + }) + ) { + return; + } try { const next = !isFollowing; setIsFollowing(next); @@ -438,7 +475,10 @@ const PlaybackItem = ({ { try { - if (!currentUID || !item?.id) return; + if (!item?.id) return; + if (!ensureAuthenticated(currentUID)) { + return; + } const nextLiked = !isLiked; setIsLiked(nextLiked); setLikesCount((c) => Math.max(0, c + (nextLiked ? 1 : -1))); @@ -481,18 +521,7 @@ const PlaybackItem = ({ )} { - try { - const url = item?.playbackUrl || ""; - const title = item?.title || "Partager"; - const base = item?.title - ? `Découvre « ${item.title} » sur MusicLand` - : `Découvre ce playback sur MusicLand`; - const message = url ? `${base}\n${url}` : base; - - await Share.share({ message, title, url }); - } catch (_e) {} - }} + onPress={handleShare} style={[styles.actionButton, styles.actionSpacing]} > { ], }); } catch (error) { - // Pas d'utilisateur connecté => vers Login + // Pas d'utilisateur connecté => accueil avec tab bar navigation.reset({ index: 0, routes: [ { - name: Routes.LandingPage, + name: Routes.BottomTab, }, ], }); diff --git a/src/utils/authRedirect.js b/src/utils/authRedirect.js new file mode 100644 index 0000000..af25001 --- /dev/null +++ b/src/utils/authRedirect.js @@ -0,0 +1,40 @@ +import { getGlobal } from "reactn"; +import { navigate } from "../navigation/NavigationService"; +import { Routes } from "../navigation/Routes"; + +const redirectToRegister = () => { + try { + const activeRoute = getGlobal()?.activeRouteName; + if (activeRoute === Routes.Register) { + return; + } + } catch (error) { + console.warn("authRedirect: unable to read active route", error); + } + navigate(Routes.Register); +}; + +export const ensureAuthenticated = (currentUID, options = {}) => { + const { onIntercept } = options || {}; + if (currentUID) { + return true; + } + try { + if (typeof onIntercept === "function") { + onIntercept(); + } + } catch (error) { + console.warn("authRedirect: onIntercept error", error); + } + redirectToRegister(); + return false; +}; + +export const withAuthGuard = async (currentUID, handler, options = {}) => { + if (!ensureAuthenticated(currentUID, options)) { + return; + } + return handler?.(); +}; + +export default ensureAuthenticated; diff --git a/src/utils/likes.js b/src/utils/likes.js index 8990b54..e2f66c8 100644 --- a/src/utils/likes.js +++ b/src/utils/likes.js @@ -1,4 +1,5 @@ import { arrayRemove, arrayUnion, projectsRef } from "../config/firebase"; +import { ensureAuthenticated } from "./authRedirect"; export const LIKE_TARGET = { SONG: "song", @@ -31,7 +32,8 @@ export const toggleProjectLike = async ({ currentUID, next, }) => { - if (!projectId || !currentUID) return; + if (!projectId) return; + if (!ensureAuthenticated(currentUID)) return; const fieldPath = getLikeFieldPath(target); await projectsRef.doc(projectId).set( { @@ -42,4 +44,3 @@ export const toggleProjectLike = async ({ { merge: true } ); }; - diff --git a/src/utils/shareSheet.js b/src/utils/shareSheet.js index 92e39c8..07af3cc 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,49 @@ export const createMusicSharePayload = ({ projectId, title, artist } = {}) => { shareTitle: title ? `${title} - MusicLand` : "MusicLand", shareMessage, url: shareUrl, - qrUrl: shareUrl, + qrUrl, + copyUrl: shareUrl, + linkLabel: shareUrl, + appLink: schemeDeepLink, + }; +}; + +export const createPlaybackSharePayload = ({ + projectId, + title, + artist, + playbackUrl, +} = {}) => { + if (!projectId) return null; + + const baseUrl = trimTrailingSlash(resolveBaseShareUrl()); + if (!baseUrl) return null; + const encodedProjectId = encodeURIComponent(projectId); + const sharePath = `playback/${encodedProjectId}`; + const shareUrl = `${baseUrl}/${sharePath}`; + const schemeDeepLink = createSchemeDeepLink(sharePath); + const qrUrl = createShareQrUrl({ + sharePath, + fallbackUrl: playbackUrl || shareUrl, + }); + + let shareMessage = "Découvre ce playback sur MusicLand."; + if (title) { + shareMessage = `Découvre "${title}"`; + if (artist) { + shareMessage += ` par ${artist}`; + } + shareMessage += " sur MusicLand."; + } else if (artist) { + shareMessage = `Découvre ce playback de ${artist} sur MusicLand.`; + } + + return { + heading: "Partager le playback", + shareTitle: title ? `${title} - MusicLand` : "MusicLand", + shareMessage, + url: shareUrl, + qrUrl, copyUrl: shareUrl, linkLabel: shareUrl, appLink: schemeDeepLink, diff --git a/vercel.json b/vercel.json index f732ca8..fbdfefb 100644 --- a/vercel.json +++ b/vercel.json @@ -1,12 +1,15 @@ { "rewrites": [ + { "source": "/link", "destination": "/index.html" }, + { "source": "/link/:path*", "destination": "/index.html" }, { "source": "/music/:musicId", "destination": "/index.html" }, { "source": "/tasks/:taskId", "destination": "/index.html" }, - { "source": "/playback/:playbackId", "destination": "/index.html" } + { "source": "/playback/:playbackId", "destination": "/index.html" }, + { "source": "/playbacks/:playbackId", "destination": "/index.html" } ], "headers": [ { - "source": "/.well-known/apple-app-site-association", + "source": "/.web/well-known/apple-app-site-association", "headers": [ { "key": "Content-Type",