From 19e925e1783224641565d7a526031d41d61b24b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?No=C3=A9=20Malzieu?= Date: Thu, 10 Oct 2024 17:44:46 +0200 Subject: [PATCH] Noe/text input deeplink (#921) * feat: dm with text input, even from frames * make nav helper more robust for dm deeplinks * Deep link to group, handle group not found * Allow converse deep link in frames * handle converse://?text= deeplink * add /group app link * use URL to parse frame action target * Add test --- android/app/src/main/AndroidManifest.xml | 2 + .../Chat/ChatPlaceholder/ChatPlaceholder.tsx | 11 +- components/Chat/Frame/FramePreview.tsx | 19 ++- components/ConversationListItem.tsx | 2 +- .../StateHandlers/InitialStateHandler.tsx | 13 +- i18n/translations/en.ts | 4 + index.js | 1 + screens/Conversation.tsx | 5 +- screens/Navigation/ConversationNav.tsx | 2 +- screens/Navigation/Navigation.tsx | 2 +- .../SplitRightStackNavigation.tsx | 2 +- .../SplitScreenNavigation.tsx | 2 +- screens/Navigation/navHelpers.test.ts | 134 ++++++++++++++++ screens/Navigation/navHelpers.ts | 143 +++++++++++++++--- utils/conversation.ts | 4 +- utils/navigation.ts | 36 +++++ 16 files changed, 329 insertions(+), 53 deletions(-) create mode 100644 screens/Navigation/navHelpers.test.ts diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index dfe517bd8..33c35ee79 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -92,6 +92,8 @@ + + diff --git a/components/Chat/ChatPlaceholder/ChatPlaceholder.tsx b/components/Chat/ChatPlaceholder/ChatPlaceholder.tsx index 3ba3ee14a..6d3edcbbc 100644 --- a/components/Chat/ChatPlaceholder/ChatPlaceholder.tsx +++ b/components/Chat/ChatPlaceholder/ChatPlaceholder.tsx @@ -1,5 +1,6 @@ import { translate } from "@i18n"; import { actionSheetColors, textPrimaryColor } from "@styles/colors"; +import { isGroupTopic } from "@utils/groupUtils/groupId"; import { Keyboard, Platform, @@ -31,6 +32,7 @@ type Props = { }; export default function ChatPlaceholder({ messagesCount }: Props) { + const topic = useConversationContext("topic"); const conversation = useConversationContext("conversation"); const isBlockedPeer = useConversationContext("isBlockedPeer"); const onReadyToFocus = useConversationContext("onReadyToFocus"); @@ -62,9 +64,13 @@ export default function ChatPlaceholder({ messagesCount }: Props) { > {!conversation && ( - + {!topic && } - Opening your conversation + {topic + ? isGroupTopic(topic) + ? translate("group_not_found") + : translate("conversation_not_found") + : translate("opening_conversation")} )} @@ -152,6 +158,7 @@ const useStyles = () => { textAlign: "center", fontSize: Platform.OS === "android" ? 16 : 17, color: textPrimaryColor(colorScheme), + paddingHorizontal: 30, }, cta: { alignSelf: "center", diff --git a/components/Chat/Frame/FramePreview.tsx b/components/Chat/Frame/FramePreview.tsx index dbbc5b200..b35027758 100644 --- a/components/Chat/Frame/FramePreview.tsx +++ b/components/Chat/Frame/FramePreview.tsx @@ -150,14 +150,21 @@ export default function FramePreview({ const onButtonPress = useCallback( async (button: FrameButtonType) => { if (button.action === "link") { + if (!button.target) return; const link = button.target; - if ( - !link || - !link.startsWith("http") || - !(await Linking.canOpenURL(link)) - ) + try { + const url = new URL(link); + if ( + (url.protocol === "http:" || + url.protocol === "https:" || + url.protocol === `${config.scheme}:`) && + (await Linking.canOpenURL(link)) + ) { + Linking.openURL(link); + } + } catch { return; - Linking.openURL(link); + } return; } if (!conversation) return; diff --git a/components/ConversationListItem.tsx b/components/ConversationListItem.tsx index a8b7f482a..228f5741e 100644 --- a/components/ConversationListItem.tsx +++ b/components/ConversationListItem.tsx @@ -204,7 +204,7 @@ const ConversationListItem = memo(function ConversationListItem({ } navigate("Conversation", { topic: conversationTopic, - message: routeParams?.frameURL, + text: routeParams?.frameURL, }); }, [ isGroupConversation, diff --git a/components/StateHandlers/InitialStateHandler.tsx b/components/StateHandlers/InitialStateHandler.tsx index 2a2aa4547..2889b0009 100644 --- a/components/StateHandlers/InitialStateHandler.tsx +++ b/components/StateHandlers/InitialStateHandler.tsx @@ -4,27 +4,16 @@ import * as Linking from "expo-linking"; import { useCallback, useEffect, useRef } from "react"; import { useColorScheme } from "react-native"; -import config from "../../config"; import { useAppStore } from "../../data/store/appStore"; import { useOnboardingStore } from "../../data/store/onboardingStore"; import { useSelect } from "../../data/store/storeHelpers"; import { + getSchemedURLFromUniversalURL, navigateToTopicWithRetry, topicToNavigateTo, } from "../../utils/navigation"; import { hideSplashScreen } from "../../utils/splash/splash"; -const getSchemedURLFromUniversalURL = (url: string) => { - let schemedURL = url; - // Handling universal links by saving a schemed URI - config.universalLinks.forEach((prefix) => { - if (schemedURL.startsWith(prefix)) { - schemedURL = Linking.createURL(schemedURL.replace(prefix, "")); - } - }); - return schemedURL; -}; - const isDevelopmentClientURL = (url: string) => { return url.includes("expo-development-client"); }; diff --git a/i18n/translations/en.ts b/i18n/translations/en.ts index 7cd229c66..e001f8e5e 100644 --- a/i18n/translations/en.ts +++ b/i18n/translations/en.ts @@ -201,6 +201,10 @@ const en = { if_you_block_contact: "If you block this contact, you will not receive messages from them anymore", opening_conversation: "Opening your conversation", + conversation_not_found: + "We couldn't find this conversation. Please try again", + group_not_found: + "We couldn't find this group. Please try again or ask someone to invite you to this group", say_hi: "Say hi", do_you_trust_this_contact: "Do you trust this contact?", do_you_want_to_join_this_group: "Do you want to join this group?", diff --git a/index.js b/index.js index a3c5e4eb1..14fe73165 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ import "./wdyr"; import "react-native-gesture-handler"; +import "react-native-url-polyfill/auto"; import { registerRootComponent } from "expo"; import App from "./App"; diff --git a/screens/Conversation.tsx b/screens/Conversation.tsx index 49ce50556..b2fe34960 100644 --- a/screens/Conversation.tsx +++ b/screens/Conversation.tsx @@ -152,8 +152,8 @@ const Conversation = ({ const mediaPreviewRef = useRef(); const messageToPrefill = useMemo( - () => route.params?.message || conversation?.messageDraft || "", - [conversation?.messageDraft, route.params?.message] + () => route.params?.text || conversation?.messageDraft || "", + [conversation?.messageDraft, route.params?.text] ); const mediaPreviewToPrefill = useMemo( () => conversation?.mediaPreview || null, @@ -289,6 +289,7 @@ const Conversation = ({ {route.params?.topic || route.params?.mainConversationWithPeer ? ( ({ + initialURL: "", +})); + +describe("getConverseStateFromPath", () => { + const navConfig = { + initialRouteName: "Chats", + screens: { + Chats: "/", + Conversation: { + path: "/conversation", + }, + NewConversation: { + path: "/newConversation", + }, + Profile: { + path: "/profile", + }, + Group: { + path: "/group", + }, + GroupLink: { + path: "/groupLink/:groupLinkId", + }, + GroupInvite: { + path: "/group-invite/:groupInviteId", + }, + ShareProfile: { + path: "/shareProfile", + }, + WebviewPreview: { + path: "/webviewPreview", + }, + }, + }; + + it("should parse simple dm deeplink", () => { + const state = getConverseStateFromPath("testNav")( + "dm?peer=0xno12.eth", + navConfig + ); + const route = state?.routes[state.routes.length - 1]; + expect(route?.name).toBe("Conversation"); + expect((route?.params as any).mainConversationWithPeer).toBe("0xno12.eth"); + expect((route?.params as any).text).toBeUndefined(); + }); + + it("should parse simple dm universal link", () => { + const state = getConverseStateFromPath("testNav")( + "dm/0xno12.eth", + navConfig + ); + const route = state?.routes[state.routes.length - 1]; + expect(route?.name).toBe("Conversation"); + expect((route?.params as any).mainConversationWithPeer).toBe("0xno12.eth"); + expect((route?.params as any).text).toBeUndefined(); + }); + + it("should parse simple group deeplink", () => { + const state = getConverseStateFromPath("testNav")( + "group?groupId=f7349f84925aaeee5816d02b7798efbc", + navConfig + ); + const route = state?.routes[state.routes.length - 1]; + expect(route?.name).toBe("Conversation"); + expect((route?.params as any).topic).toBe( + "/xmtp/mls/1/g-f7349f84925aaeee5816d02b7798efbc/proto" + ); + expect((route?.params as any).text).toBeUndefined(); + }); + + it("should parse simple group universal link", () => { + const state = getConverseStateFromPath("testNav")( + "group/f7349f84925aaeee5816d02b7798efbc", + navConfig + ); + const route = state?.routes[state.routes.length - 1]; + expect(route?.name).toBe("Conversation"); + expect((route?.params as any).topic).toBe( + "/xmtp/mls/1/g-f7349f84925aaeee5816d02b7798efbc/proto" + ); + expect((route?.params as any).text).toBeUndefined(); + }); + + it("should parse simple dm deeplink with text", () => { + const state = getConverseStateFromPath("testNav")( + "dm?peer=0xno12.eth&text=hello", + navConfig + ); + const route = state?.routes[state.routes.length - 1]; + expect(route?.name).toBe("Conversation"); + expect((route?.params as any).mainConversationWithPeer).toBe("0xno12.eth"); + expect((route?.params as any).text).toBe("hello"); + }); + + it("should parse simple dm universal link with text", () => { + const state = getConverseStateFromPath("testNav")( + "dm/0xno12.eth?text=hello", + navConfig + ); + const route = state?.routes[state.routes.length - 1]; + expect(route?.name).toBe("Conversation"); + expect((route?.params as any).mainConversationWithPeer).toBe("0xno12.eth"); + expect((route?.params as any).text).toBe("hello"); + }); + + it("should parse simple group deeplink with text", () => { + const state = getConverseStateFromPath("testNav")( + "group?groupId=f7349f84925aaeee5816d02b7798efbc&text=hello", + navConfig + ); + const route = state?.routes[state.routes.length - 1]; + expect(route?.name).toBe("Conversation"); + expect((route?.params as any).topic).toBe( + "/xmtp/mls/1/g-f7349f84925aaeee5816d02b7798efbc/proto" + ); + expect((route?.params as any).text).toBe("hello"); + }); + + it("should parse simple group universal link with text", () => { + const state = getConverseStateFromPath("testNav")( + "group/f7349f84925aaeee5816d02b7798efbc?text=hello", + navConfig + ); + const route = state?.routes[state.routes.length - 1]; + expect(route?.name).toBe("Conversation"); + expect((route?.params as any).topic).toBe( + "/xmtp/mls/1/g-f7349f84925aaeee5816d02b7798efbc/proto" + ); + expect((route?.params as any).text).toBe("hello"); + }); +}); diff --git a/screens/Navigation/navHelpers.ts b/screens/Navigation/navHelpers.ts index b4ff1e31e..424594d1a 100644 --- a/screens/Navigation/navHelpers.ts +++ b/screens/Navigation/navHelpers.ts @@ -5,31 +5,115 @@ import { listItemSeparatorColor, textPrimaryColor, } from "@styles/colors"; +import { converseEventEmitter } from "@utils/events"; import { ColorSchemeName, Platform, useWindowDimensions } from "react-native"; import { initialURL } from "../../components/StateHandlers/InitialStateHandler"; import config from "../../config"; import { isDesktop } from "../../utils/device"; -export const getConverseStateFromPath = (path: string, options: any) => { - // dm method must link to the Conversation Screen as well - // but prefilling the parameters - let pathForState = path; - if (Platform.OS === "web" && pathForState.startsWith("/")) { - pathForState = pathForState.slice(1); +export const getConverseStateFromPath = + (navigationName: string) => (path: string, options: any) => { + // dm method must link to the Conversation Screen as well + // but prefilling the parameters + let pathForState = path as string | undefined; + if (Platform.OS === "web" && pathForState?.startsWith("/")) { + pathForState = pathForState.slice(1); + } + if (pathForState?.startsWith("dm?peer=")) { + const params = new URLSearchParams(pathForState.slice(3)); + pathForState = handleConversationLink({ + navigationName, + peer: params.get("peer"), + text: params.get("text"), + }); + } else if (pathForState?.startsWith("dm/")) { + const url = new URL(`https://${config.websiteDomain}/${pathForState}`); + const params = new URLSearchParams(url.search); + const peer = url.pathname.slice(4).trim().toLowerCase(); + pathForState = handleConversationLink({ + navigationName, + peer, + text: params.get("text"), + }); + } else if (pathForState?.startsWith("group?groupId=")) { + const params = new URLSearchParams(pathForState.slice(6)); + pathForState = handleConversationLink({ + navigationName, + groupId: params.get("groupId"), + text: params.get("text"), + }); + } else if (pathForState?.startsWith("group/")) { + const url = new URL(`https://${config.websiteDomain}/${pathForState}`); + const params = new URLSearchParams(url.search); + const groupId = url.pathname.slice(7).trim(); + pathForState = handleConversationLink({ + navigationName, + groupId, + text: params.get("text"), + }); + } else if (pathForState?.startsWith("groupInvite")) { + // TODO: Remove this once enough users have updated (September 30, 2024) + pathForState = pathForState.replace("groupInvite", "group-invite"); + } else if (pathForState?.startsWith("?text=")) { + const url = new URL(`https://${config.websiteDomain}/${pathForState}`); + const params = new URLSearchParams(url.search); + setOpenedConversationText({ text: params.get("text"), navigationName }); + } + + // Prevent navigation + if (!pathForState) return null; + + const state = getStateFromPath(pathForState, options); + return state; + }; + +const handleConversationLink = ({ + navigationName, + groupId, + peer, + text, +}: { + navigationName: string; + groupId?: string | null; + peer?: string | null; + text?: string | null; +}) => { + if (!groupId && !peer) { + setOpenedConversationText({ text, navigationName }); + return; } - if (pathForState.startsWith("dm?peer=")) { - const peer = pathForState.slice(8).trim().toLowerCase(); - pathForState = `conversation?mainConversationWithPeer=${peer}&focus=true`; - } else if (pathForState.startsWith("dm/")) { - const peer = pathForState.slice(3).trim().toLowerCase(); - pathForState = `conversation?mainConversationWithPeer=${peer}&focus=true`; - } else if (pathForState.startsWith("groupInvite")) { - // TODO: Remove this once enough users have updated (September 30, 2024) - pathForState = pathForState.replace("groupInvite", "group-invite"); + const parameters: { [param: string]: string } = { focus: "true" }; + if (peer) { + parameters["mainConversationWithPeer"] = peer; + } else if (groupId) { + parameters["topic"] = `/xmtp/mls/1/g-${groupId}/proto`; + } + if (text) { + parameters["text"] = text; + } + const queryString = new URLSearchParams(parameters).toString(); + return `conversation?${queryString}`; +}; + +const setOpenedConversationText = ({ + text, + navigationName, +}: { + text?: string | null; + navigationName: string; +}) => { + if (text) { + // If navigating but no group or peer, we can still prefill message for current convo + const navigationState = navigationStates[navigationName]; + const currentRoutes = navigationState?.state.routes || []; + if ( + currentRoutes.length > 0 && + currentRoutes[currentRoutes.length - 1].name === "Conversation" + ) { + converseEventEmitter.emit("setCurrentConversationInputValue", text); + } } - const state = getStateFromPath(pathForState, options); - return state; }; export const getConverseInitialURL = () => { @@ -74,15 +158,24 @@ export const screenListeners = currentRoute.params?.peer !== newRoute.params?.peer ) { shouldReplace = true; - } else if ( - newRoute.name === "Conversation" && - ((newRoute.params?.mainConversationWithPeer && + } else if (newRoute.name === "Conversation") { + const isNewPeer = + newRoute.params?.mainConversationWithPeer && newRoute.params?.mainConversationWithPeer !== - currentRoute.params?.mainConversationWithPeer) || - (newRoute.params?.topic && - newRoute.params?.topic !== currentRoute.params?.topic)) - ) { - shouldReplace = true; + currentRoute.params?.mainConversationWithPeer; + const isNewTopic = + newRoute.params?.topic && + newRoute.params?.topic !== currentRoute.params?.topic; + if (isNewPeer || isNewTopic) { + shouldReplace = true; + } else if (newRoute.params?.message) { + // If navigating to the same route but with a message param + // we can set the input value (for instance from a frame) + converseEventEmitter.emit( + "setCurrentConversationInputValue", + newRoute.params.message + ); + } } } if (shouldReplace) { diff --git a/utils/conversation.ts b/utils/conversation.ts index 414062960..acbf2f6d0 100644 --- a/utils/conversation.ts +++ b/utils/conversation.ts @@ -18,9 +18,9 @@ import { getMatchedPeerAddresses } from "./search"; import { sentryTrackMessage } from "./sentry"; import { TextInputWithValue, addressPrefix } from "./str"; import { isTransactionMessage } from "./transaction"; +import config from "../config"; import { isOnXmtp } from "./xmtpRN/client"; import { isContentType } from "./xmtpRN/contentTypes"; -import config from "../config"; import { createPendingConversation } from "../data/helpers/conversations/pendingConversations"; import { getChatStore, useChatStore } from "../data/store/accountsStore"; import { @@ -270,6 +270,7 @@ export const openMainConversationWithPeer = async ( }; export type ConversationContextType = { + topic?: string; conversation?: XmtpConversationWithUpdate; inputRef: MutableRefObject; mediaPreviewRef: MutableRefObject; @@ -287,6 +288,7 @@ export type ConversationContextType = { }; export const ConversationContext = createContext({ + topic: undefined, conversation: undefined, inputRef: createRef() as MutableRefObject, mediaPreviewRef: createRef() as MutableRefObject, diff --git a/utils/navigation.ts b/utils/navigation.ts index e6ff2f589..6594842c7 100644 --- a/utils/navigation.ts +++ b/utils/navigation.ts @@ -1,5 +1,9 @@ +import * as Linking from "expo-linking"; +import { Linking as RNLinking } from "react-native"; + import logger from "./logger"; import { loadSavedNotificationMessagesToContext } from "./notifications"; +import config from "../config"; import { currentAccount, getChatStore } from "../data/store/accountsStore"; import { XmtpConversation } from "../data/store/chatStore"; import { NavigationParamList } from "../screens/Navigation/Navigation"; @@ -55,3 +59,35 @@ export const navigateToTopicWithRetry = async () => { navigateToConversation(conversationToNavigateTo); } }; + +export const getSchemedURLFromUniversalURL = (url: string) => { + // Handling universal links by saving a schemed URI + for (const prefix of config.universalLinks) { + if (url.startsWith(prefix)) { + return Linking.createURL(url.replace(prefix, "")); + } + } + return url; +}; + +const isDMLink = (url: string) => { + for (const prefix of config.universalLinks) { + if (url.startsWith(prefix)) { + const path = url.slice(prefix.length); + if (path.toLowerCase().startsWith("dm/")) { + return true; + } + } + } + return false; +}; + +const originalOpenURL = RNLinking.openURL.bind(RNLinking); +RNLinking.openURL = (url: string) => { + // If the URL is a DM link, open it inside the app + // as a deeplink, not the browser + if (isDMLink(url)) { + return originalOpenURL(getSchemedURLFromUniversalURL(url)); + } + return originalOpenURL(url); +};