@@ -24,6 +24,7 @@ import {
2424 createNativeStackNavigator ,
2525 type NativeStackScreenProps ,
2626} from "@react-navigation/native-stack" ;
27+ import { useShareIntent } from "expo-share-intent" ;
2728import * as SplashScreen from "expo-splash-screen" ;
2829import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
2930import {
@@ -54,12 +55,18 @@ import PhotographyGuideScreen, {
5455import SettingsScreen from "../screens/SettingsScreen" ;
5556import ThemeSettingsScreen from "../screens/ThemeSettingsScreen" ;
5657import TimerScreen from "../screens/TimerScreen" ;
58+ import {
59+ type IncomingExternalLink ,
60+ normalizeSharePayloadToIncomingLinks ,
61+ subscribeToIncomingExternalLinks ,
62+ } from "../services/shareIntake" ;
5763import { syncWearPreviewRouteState } from "../services/wearPreviewPublisher" ;
5864import { startWearLiveSync } from "../services/wearSync" ;
5965import { type FavoriteLocation , useAppState } from "../state/appState" ;
6066import { useAppTheme } from "../theme/useAppTheme" ;
6167import { localYmdNow } from "../utils/date" ;
6268import { kindCodeForRecord , kindLabelFromCode } from "../utils/eclipse" ;
69+ import { type ParsedSharedMapLink , parseSharedMapLinkAsync } from "../utils/sharedMapLink" ;
6370import FirstRunOnboardingOverlay from "./FirstRunOnboardingOverlay" ;
6471import { ONBOARDING_WALKTHROUGH_STEPS , type OnboardingRouteName } from "./onboardingWalkthrough" ;
6572import SideMenu , { type MenuRouteName } from "./SideMenu" ;
@@ -104,6 +111,8 @@ type LandingRouteProps = NativeStackScreenProps<RootStackParamList, "Landing"> &
104111type TimerRouteProps = NativeStackScreenProps < RootStackParamList , "Timer" > & {
105112 catalog : EclipseRecord [ ] ;
106113 onOpenMenu : ( ) => void ;
114+ pendingSharedLocation : PendingSharedLocation | null ;
115+ onConsumePendingSharedLocation : ( id : string ) => void ;
107116} ;
108117
109118type PreviewRouteProps = NativeStackScreenProps < RootStackParamList , "Preview" > & {
@@ -197,6 +206,13 @@ type FeaturedEclipseDeepLinkAction =
197206 | { type : "open_timer" ; eclipseId : string }
198207 | { type : "open_preview" ; eclipseId : string } ;
199208
209+ type PendingSharedLocation = ParsedSharedMapLink & {
210+ id : string ;
211+ } ;
212+
213+ const INCOMING_EXTERNAL_LINK_DEDUPE_WINDOW_MS = 5_000 ;
214+ const MAX_TRACKED_INCOMING_EXTERNAL_LINKS = 20 ;
215+
200216const FEATURED_ECLIPSE_BY_SLUG : Record < string , string > = {
201217 "2026-total" : "2026-08-12T" ,
202218 "2027-total" : "2027-08-02T" ,
@@ -322,9 +338,16 @@ function LandingRoute({ navigation, catalog, onOpenMenu }: LandingRouteProps) {
322338 ) ;
323339}
324340
325- function TimerRoute ( { navigation, catalog, onOpenMenu } : TimerRouteProps ) {
341+ function TimerRoute ( {
342+ navigation,
343+ catalog,
344+ onOpenMenu,
345+ pendingSharedLocation,
346+ onConsumePendingSharedLocation,
347+ } : TimerRouteProps ) {
326348 const { state, actions } = useAppState ( ) ;
327349 const isFocused = useIsFocused ( ) ;
350+ const lastAppliedSharedLocationIdRef = useRef < string | null > ( null ) ;
328351 const [ activeEclipse , setActiveEclipse ] = useState < EclipseRecord | null > ( null ) ;
329352 const [ isActiveEclipseLoading , setIsActiveEclipseLoading ] = useState ( false ) ;
330353 const todayYmd = useMemo ( ( ) => localYmdNow ( ) , [ ] ) ;
@@ -383,6 +406,16 @@ function TimerRoute({ navigation, catalog, onOpenMenu }: TimerRouteProps) {
383406 actions . removeEclipseReminderAnchor ,
384407 ) ;
385408
409+ useEffect ( ( ) => {
410+ if ( ! pendingSharedLocation ) return ;
411+ if ( lastAppliedSharedLocationIdRef . current === pendingSharedLocation . id ) return ;
412+
413+ lastAppliedSharedLocationIdRef . current = pendingSharedLocation . id ;
414+ timerState . jumpTo ( pendingSharedLocation . lat , pendingSharedLocation . lon , 2 ) ;
415+ timerState . setStatusMessage ( "Location loaded from shared map link" ) ;
416+ onConsumePendingSharedLocation ( pendingSharedLocation . id ) ;
417+ } , [ onConsumePendingSharedLocation , pendingSharedLocation , timerState ] ) ;
418+
386419 useInAppAlarmEngine ( {
387420 isRouteFocused : isFocused ,
388421 activeEclipseId : state . activeEclipseId ,
@@ -575,9 +608,17 @@ function LocationSettingsRoute({ onOpenMenu }: RouteWithMenuProps) {
575608export default function RootNavigator ( ) {
576609 const { state : appState , hasHydratedPreferences, actions } = useAppState ( ) ;
577610 const { colors, resolvedTheme } = useAppTheme ( ) ;
611+ const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntent ( {
612+ resetOnBackground : false ,
613+ } ) ;
578614 const navigationRef = useNavigationContainerRef < RootStackParamList > ( ) ;
579615 const pendingFeaturedDeepLinkActionRef = useRef < FeaturedEclipseDeepLinkAction | null > ( null ) ;
616+ const sharedLocationSequenceRef = useRef ( 0 ) ;
617+ const recentIncomingExternalLinksRef = useRef < Array < { value : string ; receivedAtMs : number } > > ( [ ] ) ;
580618 const [ catalog , setCatalog ] = useState < EclipseRecord [ ] | null > ( null ) ;
619+ const [ pendingSharedLocation , setPendingSharedLocation ] = useState < PendingSharedLocation | null > (
620+ null ,
621+ ) ;
581622 const [ isMenuOpen , setIsMenuOpen ] = useState ( false ) ;
582623 const [ onboardingStepIndex , setOnboardingStepIndex ] = useState ( 0 ) ;
583624 const [ currentRouteName , setCurrentRouteName ] = useState < keyof RootStackParamList > ( "Landing" ) ;
@@ -686,29 +727,98 @@ export default function RootNavigator() {
686727 [ activateEclipseById , closeMenu , navigationRef ] ,
687728 ) ;
688729
730+ const queuePendingSharedLocation = useCallback (
731+ ( link : ParsedSharedMapLink ) => {
732+ sharedLocationSequenceRef . current += 1 ;
733+ const pending : PendingSharedLocation = {
734+ ...link ,
735+ id : `shared-location-${ sharedLocationSequenceRef . current } ` ,
736+ } ;
737+
738+ setPendingSharedLocation ( pending ) ;
739+ closeMenu ( ) ;
740+
741+ if ( ! navigationRef . isReady ( ) ) return ;
742+ navigationRef . navigate ( "Timer" ) ;
743+ } ,
744+ [ closeMenu , navigationRef ] ,
745+ ) ;
746+
747+ const consumePendingSharedLocation = useCallback ( ( id : string ) => {
748+ setPendingSharedLocation ( ( current ) => ( current ?. id === id ? null : current ) ) ;
749+ } , [ ] ) ;
750+
751+ const shouldProcessIncomingExternalLink = useCallback ( ( value : string ) => {
752+ const nowMs = Date . now ( ) ;
753+ const cutoffMs = nowMs - INCOMING_EXTERNAL_LINK_DEDUPE_WINDOW_MS ;
754+ const recent = recentIncomingExternalLinksRef . current . filter (
755+ ( entry ) => entry . receivedAtMs >= cutoffMs ,
756+ ) ;
757+ const isDuplicate = recent . some ( ( entry ) => entry . value === value ) ;
758+ if ( isDuplicate ) {
759+ recentIncomingExternalLinksRef . current = recent ;
760+ return false ;
761+ }
762+
763+ recent . push ( { value, receivedAtMs : nowMs } ) ;
764+ if ( recent . length > MAX_TRACKED_INCOMING_EXTERNAL_LINKS ) {
765+ recent . splice ( 0 , recent . length - MAX_TRACKED_INCOMING_EXTERNAL_LINKS ) ;
766+ }
767+ recentIncomingExternalLinksRef . current = recent ;
768+ return true ;
769+ } , [ ] ) ;
770+
689771 const handleIncomingUrl = useCallback (
690- ( url : string ) => {
691- const action = parseFeaturedEclipseDeepLink ( url ) ;
692- if ( ! action ) return false ;
772+ async ( incoming : IncomingExternalLink ) => {
773+ if ( ! shouldProcessIncomingExternalLink ( incoming . value ) ) {
774+ return false ;
775+ }
693776
694- if ( ! navigationRef . isReady ( ) ) {
695- pendingFeaturedDeepLinkActionRef . current = action ;
777+ const action = parseFeaturedEclipseDeepLink ( incoming . value ) ;
778+ if ( action ) {
779+ if ( ! navigationRef . isReady ( ) ) {
780+ pendingFeaturedDeepLinkActionRef . current = action ;
781+ return true ;
782+ }
783+
784+ runFeaturedDeepLinkAction ( action ) ;
696785 return true ;
697786 }
698787
699- runFeaturedDeepLinkAction ( action ) ;
788+ const sharedMapLink = await parseSharedMapLinkAsync ( incoming . value ) ;
789+ if ( ! sharedMapLink ) return false ;
790+
791+ queuePendingSharedLocation ( sharedMapLink ) ;
700792 return true ;
701793 } ,
702- [ navigationRef , runFeaturedDeepLinkAction ] ,
794+ [
795+ navigationRef ,
796+ queuePendingSharedLocation ,
797+ runFeaturedDeepLinkAction ,
798+ shouldProcessIncomingExternalLink ,
799+ ] ,
703800 ) ;
704801
705802 const onNavigationReady = useCallback ( ( ) => {
706803 syncWearPreviewWithRoute ( ) ;
707804 const pendingAction = pendingFeaturedDeepLinkActionRef . current ;
708- if ( ! pendingAction ) return ;
709- pendingFeaturedDeepLinkActionRef . current = null ;
710- runFeaturedDeepLinkAction ( pendingAction ) ;
711- } , [ runFeaturedDeepLinkAction , syncWearPreviewWithRoute ] ) ;
805+ if ( pendingAction ) {
806+ pendingFeaturedDeepLinkActionRef . current = null ;
807+ runFeaturedDeepLinkAction ( pendingAction ) ;
808+ return ;
809+ }
810+
811+ if ( pendingSharedLocation ) {
812+ closeMenu ( ) ;
813+ navigationRef . navigate ( "Timer" ) ;
814+ }
815+ } , [
816+ closeMenu ,
817+ navigationRef ,
818+ pendingSharedLocation ,
819+ runFeaturedDeepLinkAction ,
820+ syncWearPreviewWithRoute ,
821+ ] ) ;
712822
713823 const onNavigateFromMenu = useCallback (
714824 ( route : MenuRouteName ) => {
@@ -807,23 +917,33 @@ export default function RootNavigator() {
807917 } , [ ] ) ;
808918
809919 useEffect ( ( ) => {
810- const subscription = Linking . addEventListener ( "url" , ( { url } ) => {
811- handleIncomingUrl ( url ) ;
812- } ) ;
920+ return subscribeToIncomingExternalLinks (
921+ ( incoming ) => {
922+ void handleIncomingUrl ( incoming ) ;
923+ } ,
924+ { linking : Linking } ,
925+ ) ;
926+ } , [ handleIncomingUrl ] ) ;
813927
814- void Linking . getInitialURL ( )
815- . then ( ( url ) => {
816- if ( ! url ) return ;
817- handleIncomingUrl ( url ) ;
818- } )
819- . catch ( ( ) => {
820- // Ignore URL parsing failures and allow default linking behavior.
821- } ) ;
928+ useEffect ( ( ) => {
929+ if ( ! hasShareIntent ) return ;
822930
823- return ( ) => {
824- subscription . remove ( ) ;
825- } ;
826- } , [ handleIncomingUrl ] ) ;
931+ const shareIncomingLinks = normalizeSharePayloadToIncomingLinks ( {
932+ webUrl : shareIntent . webUrl ,
933+ text : shareIntent . text ,
934+ } ) ;
935+ if ( ! shareIncomingLinks . length ) {
936+ resetShareIntent ( ) ;
937+ return ;
938+ }
939+
940+ void ( async ( ) => {
941+ for ( const incoming of shareIncomingLinks ) {
942+ await handleIncomingUrl ( incoming ) ;
943+ }
944+ resetShareIntent ( ) ;
945+ } ) ( ) ;
946+ } , [ handleIncomingUrl , hasShareIntent , resetShareIntent , shareIntent . text , shareIntent . webUrl ] ) ;
827947
828948 if ( ! catalog ) {
829949 return < StartupLoadingScreen message = "Loading eclipse catalog..." colors = { colors } /> ;
@@ -843,7 +963,15 @@ export default function RootNavigator() {
843963 { ( props ) => < LandingRoute { ...props } catalog = { catalog } onOpenMenu = { openMenu } /> }
844964 </ Stack . Screen >
845965 < Stack . Screen name = "Timer" >
846- { ( props ) => < TimerRoute { ...props } catalog = { catalog } onOpenMenu = { openMenu } /> }
966+ { ( props ) => (
967+ < TimerRoute
968+ { ...props }
969+ catalog = { catalog }
970+ onOpenMenu = { openMenu }
971+ pendingSharedLocation = { pendingSharedLocation }
972+ onConsumePendingSharedLocation = { consumePendingSharedLocation }
973+ />
974+ ) }
847975 </ Stack . Screen >
848976 < Stack . Screen name = "Preview" >
849977 { ( props ) => < PreviewRoute { ...props } onOpenMenu = { openMenu } /> }
0 commit comments