From baaaf1698abe29d2ad359a6c35ff82b8509d5847 Mon Sep 17 00:00:00 2001 From: Nicolas Burtey Date: Mon, 8 Jan 2024 20:32:52 -0600 Subject: [PATCH] feat: conversation screen POC --- .storybook/storybook.requires.js | 1 + .storybook/storybook.tsx | 4 +- .../contact-modal/contact-modal.tsx | 15 + app/graphql/generated.gql | 31 ++ app/graphql/generated.ts | 149 +++++++ app/i18n/en/index.ts | 1 + app/i18n/i18n-types.ts | 8 + app/i18n/raw-i18n/source/en.json | 1 + app/navigation/root-navigator.tsx | 8 + app/navigation/stack-param-lists.ts | 1 + .../conversation/conversation.stories.tsx | 83 ++++ app/screens/conversation/conversation.tsx | 381 ++++++++++++++++++ .../settings-screen/settings-screen.tsx | 14 +- ios/GaloyApp.xcodeproj/project.pbxproj | 12 +- ios/Podfile.lock | 12 +- package.json | 4 +- supergraph.graphql | 32 +- yarn.lock | 71 +++- 18 files changed, 807 insertions(+), 21 deletions(-) create mode 100644 app/screens/conversation/conversation.stories.tsx create mode 100644 app/screens/conversation/conversation.tsx diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js index eca88496f5..a956ef5e64 100644 --- a/.storybook/storybook.requires.js +++ b/.storybook/storybook.requires.js @@ -67,6 +67,7 @@ const getStories = () => { "./app/screens/authentication-screen/authentication-check-screen.stories.tsx": require("../app/screens/authentication-screen/authentication-check-screen.stories.tsx"), "./app/screens/authentication-screen/authentication-screen.stories.tsx": require("../app/screens/authentication-screen/authentication-screen.stories.tsx"), "./app/screens/authentication-screen/pin-screen.stories.tsx": require("../app/screens/authentication-screen/pin-screen.stories.tsx"), + "./app/screens/conversation/conversation.stories.tsx": require("../app/screens/conversation/conversation.stories.tsx"), "./app/screens/conversion-flow/conversion-success-screen.stories.tsx": require("../app/screens/conversion-flow/conversion-success-screen.stories.tsx"), "./app/screens/earns-map-screen/earns-map-screen.stories.tsx": require("../app/screens/earns-map-screen/earns-map-screen.stories.tsx"), "./app/screens/earns-screen/earns-quiz.stories.tsx": require("../app/screens/earns-screen/earns-quiz.stories.tsx"), diff --git a/.storybook/storybook.tsx b/.storybook/storybook.tsx index f04db77a4c..de63055e6b 100644 --- a/.storybook/storybook.tsx +++ b/.storybook/storybook.tsx @@ -20,9 +20,9 @@ import { NotificationsProvider } from "../app/components/notifications" RNBootSplash.hide({ fade: true }) const StorybookUI = getStorybookUI({ - enableWebsockets: true, // for @storybook/react-native-server + enableWebsockets: true, onDeviceUI: true, - initialSelection: { kind: "Notification Card UI", name: "Default" }, + initialSelection: { kind: "Conversation Screen", name: "Default" }, shouldPersistSelection: false, }) diff --git a/app/components/contact-modal/contact-modal.tsx b/app/components/contact-modal/contact-modal.tsx index fe99b37791..14826c7396 100644 --- a/app/components/contact-modal/contact-modal.tsx +++ b/app/components/contact-modal/contact-modal.tsx @@ -4,7 +4,10 @@ import ReactNativeModal from "react-native-modal" import { CONTACT_EMAIL_ADDRESS, WHATSAPP_CONTACT_NUMBER } from "@app/config" import { useI18nContext } from "@app/i18n/i18n-react" +import { RootStackParamList } from "@app/navigation/stack-param-lists" import { openWhatsApp } from "@app/utils/external" +import { useNavigation } from "@react-navigation/native" +import { StackNavigationProp } from "@react-navigation/stack" import { Icon, ListItem, makeStyles, useTheme } from "@rneui/themed" import TelegramOutline from "./telegram.svg" @@ -16,6 +19,7 @@ export const SupportChannels = { StatusPage: "statusPage", Mattermost: "mattermost", Faq: "faq", + Chatbot: "chatbot", } as const export type SupportChannels = (typeof SupportChannels)[keyof typeof SupportChannels] @@ -44,7 +48,18 @@ const ContactModal: React.FC = ({ theme: { colors }, } = useTheme() + const { navigate } = useNavigation>() + const contactOptionList = [ + { + id: SupportChannels.Chatbot, + name: LL.support.chatbot(), + icon: , + action: () => { + navigate("chatbot") + toggleModal() + }, + }, { id: SupportChannels.StatusPage, name: LL.support.statusPage(), diff --git a/app/graphql/generated.gql b/app/graphql/generated.gql index ac65f17080..3e56172354 100644 --- a/app/graphql/generated.gql +++ b/app/graphql/generated.gql @@ -496,6 +496,23 @@ mutation quizClaim($input: QuizClaimInput!) { } } +mutation supportChatMessageAdd($input: SupportChatMessageAddInput!) { + supportChatMessageAdd(input: $input) { + errors { + message + __typename + } + supportMessage { + id + message + role + timestamp + __typename + } + __typename + } +} + mutation userContactUpdateAlias($input: UserContactUpdateAliasInput!) { userContactUpdateAlias(input: $input) { errors { @@ -1458,6 +1475,20 @@ query settingsScreen { } } +query supportChat { + me { + id + supportChat { + id + message + role + timestamp + __typename + } + __typename + } +} + query supportedCountries { globals { supportedCountries { diff --git a/app/graphql/generated.ts b/app/graphql/generated.ts index bacf4787cc..95961716b9 100644 --- a/app/graphql/generated.ts +++ b/app/graphql/generated.ts @@ -1034,6 +1034,7 @@ export type Mutation = { readonly onChainUsdPaymentSendAsBtcDenominated: PaymentSendPayload; readonly onboardingFlowStart: OnboardingFlowStartResult; readonly quizClaim: QuizClaimPayload; + readonly supportChatMessageAdd: SupportChatMessageAddPayload; /** @deprecated will be moved to AccountContact */ readonly userContactUpdateAlias: UserContactUpdateAliasPayload; readonly userEmailDelete: UserEmailDeletePayload; @@ -1259,6 +1260,11 @@ export type MutationQuizClaimArgs = { }; +export type MutationSupportChatMessageAddArgs = { + input: SupportChatMessageAddInput; +}; + + export type MutationUserContactUpdateAliasArgs = { input: UserContactUpdateAliasInput; }; @@ -1808,6 +1814,30 @@ export type SuccessPayload = { readonly success?: Maybe; }; +export type SupportChatMessageAddInput = { + readonly message: Scalars['String']['input']; +}; + +export type SupportChatMessageAddPayload = { + readonly __typename: 'SupportChatMessageAddPayload'; + readonly errors: ReadonlyArray; + readonly supportMessage?: Maybe>>; +}; + +export type SupportMessage = { + readonly __typename: 'SupportMessage'; + readonly id: Scalars['ID']['output']; + readonly message: Scalars['String']['output']; + readonly role: SupportRole; + readonly timestamp: Scalars['Timestamp']['output']; +}; + +export const SupportRole = { + Assistant: 'ASSISTANT', + User: 'USER' +} as const; + +export type SupportRole = typeof SupportRole[keyof typeof SupportRole]; /** * Give details about an individual transaction. * Galoy have a smart routing system which is automatically @@ -1992,6 +2022,7 @@ export type User = { readonly language: Scalars['Language']['output']; /** Phone number with international calling code. */ readonly phone?: Maybe; + readonly supportChat: ReadonlyArray; /** Whether TOTP is enabled for this user. */ readonly totpEnabled: Scalars['Boolean']['output']; /** @@ -2418,6 +2449,18 @@ export type UserLogoutMutationVariables = Exact<{ export type UserLogoutMutation = { readonly __typename: 'Mutation', readonly userLogout: { readonly __typename: 'SuccessPayload', readonly success?: boolean | null } }; +export type SupportChatQueryVariables = Exact<{ [key: string]: never; }>; + + +export type SupportChatQuery = { readonly __typename: 'Query', readonly me?: { readonly __typename: 'User', readonly id: string, readonly supportChat: ReadonlyArray<{ readonly __typename: 'SupportMessage', readonly id: string, readonly message: string, readonly role: SupportRole, readonly timestamp: number }> } | null }; + +export type SupportChatMessageAddMutationVariables = Exact<{ + input: SupportChatMessageAddInput; +}>; + + +export type SupportChatMessageAddMutation = { readonly __typename: 'Mutation', readonly supportChatMessageAdd: { readonly __typename: 'SupportChatMessageAddPayload', readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly message: string }>, readonly supportMessage?: ReadonlyArray<{ readonly __typename: 'SupportMessage', readonly id: string, readonly message: string, readonly role: SupportRole, readonly timestamp: number } | null> | null } }; + export type ConversionScreenQueryVariables = Exact<{ [key: string]: never; }>; @@ -3926,6 +3969,87 @@ export function useUserLogoutMutation(baseOptions?: Apollo.MutationHookOptions; export type UserLogoutMutationResult = Apollo.MutationResult; export type UserLogoutMutationOptions = Apollo.BaseMutationOptions; +export const SupportChatDocument = gql` + query supportChat { + me { + id + supportChat { + id + message + role + timestamp + } + } +} + `; + +/** + * __useSupportChatQuery__ + * + * To run a query within a React component, call `useSupportChatQuery` and pass it any options that fit your needs. + * When your component renders, `useSupportChatQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useSupportChatQuery({ + * variables: { + * }, + * }); + */ +export function useSupportChatQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(SupportChatDocument, options); + } +export function useSupportChatLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(SupportChatDocument, options); + } +export type SupportChatQueryHookResult = ReturnType; +export type SupportChatLazyQueryHookResult = ReturnType; +export type SupportChatQueryResult = Apollo.QueryResult; +export const SupportChatMessageAddDocument = gql` + mutation supportChatMessageAdd($input: SupportChatMessageAddInput!) { + supportChatMessageAdd(input: $input) { + errors { + message + } + supportMessage { + id + message + role + timestamp + } + } +} + `; +export type SupportChatMessageAddMutationFn = Apollo.MutationFunction; + +/** + * __useSupportChatMessageAddMutation__ + * + * To run a mutation, you first call `useSupportChatMessageAddMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useSupportChatMessageAddMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [supportChatMessageAddMutation, { data, loading, error }] = useSupportChatMessageAddMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useSupportChatMessageAddMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(SupportChatMessageAddDocument, options); + } +export type SupportChatMessageAddMutationHookResult = ReturnType; +export type SupportChatMessageAddMutationResult = Apollo.MutationResult; +export type SupportChatMessageAddMutationOptions = Apollo.BaseMutationOptions; export const ConversionScreenDocument = gql` query conversionScreen { me { @@ -7403,6 +7527,10 @@ export type ResolversTypes = { SignedDisplayMajorAmount: ResolverTypeWrapper; Subscription: ResolverTypeWrapper<{}>; SuccessPayload: ResolverTypeWrapper; + SupportChatMessageAddInput: SupportChatMessageAddInput; + SupportChatMessageAddPayload: ResolverTypeWrapper; + SupportMessage: ResolverTypeWrapper; + SupportRole: SupportRole; Timestamp: ResolverTypeWrapper; TotpCode: ResolverTypeWrapper; TotpRegistrationId: ResolverTypeWrapper; @@ -7616,6 +7744,9 @@ export type ResolversParentTypes = { SignedDisplayMajorAmount: Scalars['SignedDisplayMajorAmount']['output']; Subscription: {}; SuccessPayload: SuccessPayload; + SupportChatMessageAddInput: SupportChatMessageAddInput; + SupportChatMessageAddPayload: SupportChatMessageAddPayload; + SupportMessage: SupportMessage; Timestamp: Scalars['Timestamp']['output']; TotpCode: Scalars['TotpCode']['output']; TotpRegistrationId: Scalars['TotpRegistrationId']['output']; @@ -8180,6 +8311,7 @@ export type MutationResolvers>; onboardingFlowStart?: Resolver>; quizClaim?: Resolver>; + supportChatMessageAdd?: Resolver>; userContactUpdateAlias?: Resolver>; userEmailDelete?: Resolver; userEmailRegistrationInitiate?: Resolver>; @@ -8487,6 +8619,20 @@ export type SuccessPayloadResolvers; }; +export type SupportChatMessageAddPayloadResolvers = { + errors?: Resolver, ParentType, ContextType>; + supportMessage?: Resolver>>, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type SupportMessageResolvers = { + id?: Resolver; + message?: Resolver; + role?: Resolver; + timestamp?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export interface TimestampScalarConfig extends GraphQLScalarTypeConfig { name: 'Timestamp'; } @@ -8568,6 +8714,7 @@ export type UserResolvers; language?: Resolver; phone?: Resolver, ParentType, ContextType>; + supportChat?: Resolver, ParentType, ContextType>; totpEnabled?: Resolver; username?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -8808,6 +8955,8 @@ export type Resolvers = { SignedDisplayMajorAmount?: GraphQLScalarType; Subscription?: SubscriptionResolvers; SuccessPayload?: SuccessPayloadResolvers; + SupportChatMessageAddPayload?: SupportChatMessageAddPayloadResolvers; + SupportMessage?: SupportMessageResolvers; Timestamp?: GraphQLScalarType; TotpCode?: GraphQLScalarType; TotpRegistrationId?: GraphQLScalarType; diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts index b5a28379e8..93cc83e425 100644 --- a/app/i18n/en/index.ts +++ b/app/i18n/en/index.ts @@ -2705,6 +2705,7 @@ const en: BaseTranslation = { faq: "FAQ", enjoyingApp: "Enjoying the app?", statusPage: "Status Page", + chatbot: "Chatbot", telegram: "Telegram", mattermost: "Mattermost", thankYouText: "Thank you for the feedback, would you like to suggest an improvement?", diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index f317b00e07..f28e981699 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -8459,6 +8459,10 @@ type RootTranslation = { * S​t​a​t​u​s​ ​P​a​g​e */ statusPage: string + /** + * C​h​a​t​b​o​t + */ + chatbot: string /** * T​e​l​e​g​r​a​m */ @@ -17292,6 +17296,10 @@ export type TranslationFunctions = { * Status Page */ statusPage: () => LocalizedString + /** + * Chatbot + */ + chatbot: () => LocalizedString /** * Telegram */ diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json index 9e5db6b1a0..0b3fd9c43e 100644 --- a/app/i18n/raw-i18n/source/en.json +++ b/app/i18n/raw-i18n/source/en.json @@ -2599,6 +2599,7 @@ "faq": "FAQ", "enjoyingApp": "Enjoying the app?", "statusPage": "Status Page", + "chatbot": "Chatbot", "telegram": "Telegram", "mattermost": "Mattermost", "thankYouText": "Thank you for the feedback, would you like to suggest an improvement?", diff --git a/app/navigation/root-navigator.tsx b/app/navigation/root-navigator.tsx index 25ef6617b5..3cca209143 100644 --- a/app/navigation/root-navigator.tsx +++ b/app/navigation/root-navigator.tsx @@ -5,6 +5,7 @@ import LearnIcon from "@app/assets/icons/learn.svg" import MapIcon from "@app/assets/icons/map.svg" import { useIsAuthed } from "@app/graphql/is-authed-context" import { useI18nContext } from "@app/i18n/i18n-react" +import { ConversationScreen } from "@app/screens/conversation/conversation" import { ConversionConfirmationScreen, ConversionDetailsScreen, @@ -409,6 +410,13 @@ export const RootStack = () => { title: LL.FullOnboarding.title(), }} /> + ) } diff --git a/app/navigation/stack-param-lists.ts b/app/navigation/stack-param-lists.ts index cfc73f367a..c7e6bacb96 100644 --- a/app/navigation/stack-param-lists.ts +++ b/app/navigation/stack-param-lists.ts @@ -96,6 +96,7 @@ export type RootStackParamList = { totpLoginValidate: { authToken: string } webView: { url: string; initialTitle?: string } fullOnboardingFlow: undefined + chatbot: undefined } export type PeopleStackParamList = { diff --git a/app/screens/conversation/conversation.stories.tsx b/app/screens/conversation/conversation.stories.tsx new file mode 100644 index 0000000000..f9b8896487 --- /dev/null +++ b/app/screens/conversation/conversation.stories.tsx @@ -0,0 +1,83 @@ +import * as React from "react" + +import { MockedProvider } from "@apollo/client/testing" +import { Meta } from "@storybook/react" + +import { StoryScreen } from "../../../.storybook/views" +import { createCache } from "../../graphql/cache" +import { SupportChatDocument } from "../../graphql/generated" +import { IsAuthedContextProvider } from "../../graphql/is-authed-context" +import { ConversationScreen } from "./conversation" + +const mockEmpty = [ + { + request: { + query: SupportChatDocument, + }, + result: { + data: { + me: { + __typename: "User", + id: "70df9822-efe0-419c-b864-c9efa99872ea", + supportChat: [], + }, + __typename: "Query", + }, + }, + }, +] + +const mockShort = [ + { + request: { + query: SupportChatDocument, + }, + result: { + data: { + me: { + __typename: "User", + id: "70df9822-efe0-419c-b864-c9efa99872ea", + supportChat: [ + { + __typename: "SupportChat", + id: "1", + message: "Hello", + role: "user", + timestamp: 1677184189, + }, + { + __typename: "SupportChat", + id: "2", + message: "Hi", + role: "assistant", + timestamp: 1677184190, + }, + ], + }, + __typename: "Query", + }, + }, + }, +] + +export default { + title: "Conversation Screen", + component: ConversationScreen, + decorators: [(Story) => {Story()}], +} as Meta + +export const Empty = () => ( + + + + + +) + +export const Default = () => ( + + + + + +) diff --git a/app/screens/conversation/conversation.tsx b/app/screens/conversation/conversation.tsx new file mode 100644 index 0000000000..70148a16e1 --- /dev/null +++ b/app/screens/conversation/conversation.tsx @@ -0,0 +1,381 @@ +import { useState, useRef } from "react" +import { + ActivityIndicator, + FlatList, + Keyboard, + TouchableHighlight, + View, +} from "react-native" +import { TextInput } from "react-native-gesture-handler" +import Icon from "react-native-vector-icons/Ionicons" + +import { gql } from "@apollo/client" +import { Screen } from "@app/components/screen" +import { + SupportChatDocument, + SupportChatQuery, + SupportRole, + useSupportChatMessageAddMutation, + useSupportChatQuery, +} from "@app/graphql/generated" +import { useActionSheet } from "@expo/react-native-action-sheet" +import Clipboard from "@react-native-clipboard/clipboard" +import { Text, makeStyles, useTheme } from "@rneui/themed" +import Markdown from "@ronradtke/react-native-markdown-display" + +type SupportChatMe = SupportChatQuery["me"] +type SupportChatArray = NonNullable["supportChat"] +type SupportChatMessage = NonNullable[number] + +gql` + query supportChat { + me { + id + supportChat { + id + message + role + timestamp + } + } + } + + mutation supportChatMessageAdd($input: SupportChatMessageAddInput!) { + supportChatMessageAdd(input: $input) { + errors { + message + } + supportMessage { + id + message + role + timestamp + } + } + } +` + +export const ConversationScreen = () => { + const styles = useStyles() + const { theme } = useTheme() + + const supportChatQuery = useSupportChatQuery({ fetchPolicy: "network-only" }) + const supportChat = supportChatQuery.data?.me?.supportChat ?? [] + + const flatListRef = useRef>(null) + + const [supportChatMessageAdd, { loading }] = useSupportChatMessageAddMutation() + + const [input, setInput] = useState("") + const [pendingInput, setPendingInput] = useState("") + + // TODO: replace with cache: + // ie: adding the SupportRole messaage to the supportChat cache + const supportChatMaybeInput = pendingInput + ? [ + ...supportChat, + { + role: SupportRole.User, + message: pendingInput, + timestamp: new Date().getTime(), + id: "pending", + __typename: "SupportMessage" as const, + }, + ] + : supportChat + + const { showActionSheetWithOptions } = useActionSheet() + + const copyToClipboard = (text: string) => { + Clipboard.setString(text) + } + + async function addMessageToThread() { + try { + if (!input) return + Keyboard.dismiss() + setPendingInput(input) + setInput("") + setTimeout(() => { + flatListRef.current?.scrollToEnd() + }, 1000) + await supportChatMessageAdd({ + variables: { input: { message: input } }, + update: (cache, { data }) => { + // If mutation didn't return any data, return early + if (!data || !data.supportChatMessageAdd.supportMessage) return + + // Add the new message to the chat array + const newMessages = data.supportChatMessageAdd.supportMessage + + // Write the updated chats back to the cache + cache.writeQuery({ + query: SupportChatDocument, + data: { + me: { + newMessages, + }, + }, + }) + }, + }) + } catch (err) { + console.log("error: ", err) + } finally { + setPendingInput("") + setTimeout(() => { + const indexBeforeLast = supportChatMaybeInput.length - 1 + if (indexBeforeLast >= 0) { + flatListRef.current?.scrollToIndex({ index: indexBeforeLast, animated: true }) + } + }, 500) + } + } + + function onChangeInputText(v: string) { + setInput(v) + } + + async function clearChat() { + if (loading) return + // setOpenaiResponse([]) + setInput("") + } + + async function showClipboardActionsheet(text: string) { + console.log("showClipboardActionsheet", text) + + const cancelButtonIndex = 2 + showActionSheetWithOptions( + { + options: ["Copy to clipboard", "Clear chat", "cancel"], + cancelButtonIndex, + }, + (selectedIndex) => { + if (selectedIndex === Number(0)) { + copyToClipboard(text) + } + if (selectedIndex === 1) { + clearChat() + } + }, + ) + } + + function renderItem({ item, index }: { item: SupportChatMessage; index: number }) { + return ( + + {item.role === SupportRole.User && ( + + + {item.message} + + + )} + {item.role === SupportRole.Assistant && ( + + {item.message} + showClipboardActionsheet(item.message)} + underlayColor={"transparent"} + > + + + + + + )} + + ) + } + + return ( + + + {loading && ( + + + + )} + + + + + + + + + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + optionsIconWrapper: { + padding: 10, + paddingTop: 9, + alignItems: "flex-end", + }, + promptResponse: { + marginTop: 10, + }, + textStyleContainer: { + borderWidth: 1, + marginRight: 25, + borderColor: colors.grey3, + padding: 15, + paddingBottom: 6, + paddingTop: 5, + margin: 10, + marginTop: 0, + borderRadius: 13, + }, + textStyle: { + body: { + color: colors.grey0, + }, + paragraph: { + color: colors.grey0, + fontSize: 16, + }, + heading1: { + color: colors.grey0, + marginVertical: 5, + }, + heading2: { + marginTop: 20, + color: colors.grey0, + marginBottom: 5, + }, + heading3: { + marginTop: 20, + color: colors.grey0, + marginBottom: 5, + }, + heading4: { + marginTop: 10, + color: colors.grey0, + marginBottom: 5, + }, + heading5: { + marginTop: 10, + color: colors.grey0, + marginBottom: 5, + }, + heading6: { + color: colors.grey0, + marginVertical: 5, + }, + /* eslint-disable camelcase */ + list_item: { + marginTop: 7, + fontSize: 16, + }, + ordered_list_icon: { + color: colors.grey0, + fontSize: 16, + }, + bullet_list: { + marginTop: 10, + }, + ordered_list: { + marginTop: 7, + }, + bullet_list_icon: { + color: colors.grey0, + fontSize: 16, + }, + code_inline: { + color: colors.grey1, + backgroundColor: colors.grey4, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, .1)", + }, + /* eslint-enable camelcase */ + hr: { + backgroundColor: "rgba(255, 255, 255, .1)", + height: 1, + }, + fence: { + marginVertical: 5, + padding: 10, + color: colors.grey1, + backgroundColor: colors.grey4, + borderColor: "rgba(255, 255, 255, .1)", + }, + tr: { + borderBottomWidth: 1, + borderColor: "rgba(255, 255, 255, .2)", + flexDirection: "row", + }, + table: { + marginTop: 7, + borderWidth: 1, + borderColor: "rgba(255, 255, 255, .2)", + borderRadius: 3, + }, + blockquote: { + backgroundColor: "#312e2e", + borderColor: "#CCC", + borderLeftWidth: 4, + marginLeft: 5, + paddingHorizontal: 5, + marginVertical: 5, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + promptTextContainer: { + flex: 1, + alignItems: "flex-end", + marginRight: 15, + marginLeft: 24, + }, + promptTextWrapper: { + borderRadius: 8, + borderTopRightRadius: 0, + backgroundColor: colors._lightBlue, + }, + promptText: { + color: colors._white, + paddingVertical: 5, + paddingHorizontal: 9, + fontSize: 16, + }, + chatInputContainer: { + paddingTop: 5, + borderColor: colors.grey3, + width: "100%", + flexDirection: "row", + alignItems: "center", + paddingBottom: 5, + }, + input: { + flex: 1, + borderWidth: 1, + borderRadius: 99, + color: colors.grey0, + marginHorizontal: 10, + paddingVertical: 10, + paddingHorizontal: 21, + paddingRight: 39, + borderColor: colors.grey4, + }, + chatButton: { + marginRight: 14, + padding: 5, + borderRadius: 99, + backgroundColor: colors._lightBlue, + }, + activityIndicator: { padding: 10 }, +})) diff --git a/app/screens/settings-screen/settings-screen.tsx b/app/screens/settings-screen/settings-screen.tsx index fae26d1b35..40ca0d1e14 100644 --- a/app/screens/settings-screen/settings-screen.tsx +++ b/app/screens/settings-screen/settings-screen.tsx @@ -10,6 +10,7 @@ import ContactModal, { } from "@app/components/contact-modal/contact-modal" import { SetLightningAddressModal } from "@app/components/set-lightning-address-modal" import { + useBetaQuery, useSettingsScreenQuery, useWalletCsvTransactionsLazyQuery, } from "@app/graphql/generated" @@ -87,6 +88,10 @@ export const SettingsScreen: React.FC = () => { skip: !isAtLeastLevelZero, }) + // get beta flag + const betaQuery = useBetaQuery() + const beta = betaQuery.data?.beta ?? false + const { displayCurrency } = useDisplayCurrency() const username = data?.me?.username ?? undefined @@ -295,12 +300,17 @@ export const SettingsScreen: React.FC = () => { icon: "help-circle-outline", id: "contact-us", action: () => { - setContactMethods([ + const contactMethods: SupportChannels[] = [ SupportChannels.Faq, SupportChannels.StatusPage, SupportChannels.Email, SupportChannels.WhatsApp, - ]) + ] + if (beta) { + contactMethods.push(SupportChannels.Chatbot) + } + + setContactMethods(contactMethods) toggleIsContactModalVisible() }, enabled: true, diff --git a/ios/GaloyApp.xcodeproj/project.pbxproj b/ios/GaloyApp.xcodeproj/project.pbxproj index 504e8d3025..608d4fe5af 100644 --- a/ios/GaloyApp.xcodeproj/project.pbxproj +++ b/ios/GaloyApp.xcodeproj/project.pbxproj @@ -574,7 +574,11 @@ ONLY_ACTIVE_ARCH = YES; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = "$(inherited)"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-Wl", + "-ld_classic", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; }; @@ -637,7 +641,11 @@ MTL_ENABLE_DEBUG_INFO = NO; OTHER_CFLAGS = "$(inherited)"; OTHER_CPLUSPLUSFLAGS = "$(inherited)"; - OTHER_LDFLAGS = "$(inherited)"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-Wl", + "-ld_classic", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 964763f27b..635597e0c9 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -963,7 +963,7 @@ SPEC CHECKSUMS: AppCheckCore: d0d4bcb6f90fd9f69958da5350467b79026b38c7 boost: 57d2868c099736d80fcd648bf211b4431e51a558 BVLinearGradient: 880f91a7854faff2df62518f0281afb1c60d49a3 - DoubleConversion: fea03f2699887d960129cc54bba7e52542b6f953 + DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 FBLazyVector: 5fbbff1d7734827299274638deb8ba3024f6c597 FBReactNativeSpec: 638095fe8a01506634d77b260ef8a322019ac671 Firebase: 4453b799f72f625384dc23f412d3be92b0e3b2a0 @@ -981,11 +981,11 @@ SPEC CHECKSUMS: FirebaseSessions: f06853e30f99fe42aa511014d7ee6c8c319f08a3 FirebaseSharedSwift: c92645b392db3c41a83a0aa967de16f8bad25568 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 - glog: 035f1e36e53b355cf70f6434d161b36e7d21fecd - GoogleAppMeasurement: 70ce9aa438cff1cfb31ea3e660bcc67734cb716e - GoogleDataTransport: 57c22343ab29bc686febbf7cbb13bad167c2d8fe - GoogleUtilities: 0759d1a57ebb953965c2dfe0ba4c82e95ccc2e34 - GT3Captcha-iOS: 5e3b1077834d8a9d6f4d64a447a30af3e14affe6 + glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b + GoogleAppMeasurement: a65314d316443969ed3d3709b3a187448ed6418f + GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a + GoogleUtilities: d053d902a8edaa9904e1bd00c37535385b8ed152 + GT3Captcha-iOS: 3e7737ece3b2210ba19802be381b9aa88007f045 hermes-engine: 9180d43df05c1ed658a87cc733dc3044cf90c00a libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 nanopb: d4d75c12cd1316f4a64e3c6963f879ecd4b5e0d5 diff --git a/package.json b/package.json index e5821bbbdb..c4e47a3520 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,9 @@ "e2e:test": "detox test --configuration" }, "dependencies": { - "@apollo/client": "^3.9.6", + "@apollo/client": "^3.9.9", "@bitcoinerlab/secp256k1": "^1.0.5", + "@expo/react-native-action-sheet": "^4.0.1", "@formatjs/intl-getcanonicallocales": "^2.3.0", "@formatjs/intl-locale": "^3.4.3", "@formatjs/intl-relativetimeformat": "^11.2.10", @@ -78,6 +79,7 @@ "@react-navigation/stack": "^6.3.20", "@rneui/base": "^4.0.0-rc.8", "@rneui/themed": "^4.0.0-rc.8", + "@ronradtke/react-native-markdown-display": "^8.0.0", "apollo3-cache-persist": "^0.14.1", "axios": "^1.6.2", "bech32": "^2.0.0", diff --git a/supergraph.graphql b/supergraph.graphql index 5ff862d20e..06ee76bc32 100644 --- a/supergraph.graphql +++ b/supergraph.graphql @@ -1275,6 +1275,7 @@ type Mutation onChainUsdPaymentSend(input: OnChainUsdPaymentSendInput!): PaymentSendPayload! @join__field(graph: GALOY) onChainUsdPaymentSendAsBtcDenominated(input: OnChainUsdPaymentSendAsBtcDenominatedInput!): PaymentSendPayload! @join__field(graph: GALOY) quizClaim(input: QuizClaimInput!): QuizClaimPayload! @join__field(graph: GALOY) + supportChatMessageAdd(input: SupportChatMessageAddInput!): SupportChatMessageAddPayload! @join__field(graph: GALOY) userContactUpdateAlias(input: UserContactUpdateAliasInput!): UserContactUpdateAliasPayload! @join__field(graph: GALOY) @deprecated(reason: "will be moved to AccountContact") userEmailDelete: UserEmailDeletePayload! @join__field(graph: GALOY) userEmailRegistrationInitiate(input: UserEmailRegistrationInitiateInput!): UserEmailRegistrationInitiatePayload! @join__field(graph: GALOY) @@ -1803,6 +1804,35 @@ type SuccessPayload success: Boolean } +input SupportChatMessageAddInput + @join__type(graph: GALOY) +{ + message: String! +} + +type SupportChatMessageAddPayload + @join__type(graph: GALOY) +{ + errors: [Error!]! + supportMessage: [SupportMessage] +} + +type SupportMessage + @join__type(graph: GALOY) +{ + id: ID! + message: String! + role: SupportRole! + timestamp: Timestamp! +} + +enum SupportRole + @join__type(graph: GALOY) +{ + ASSISTANT @join__enumValue(graph: GALOY) + USER @join__enumValue(graph: GALOY) +} + """ Timestamp field, serialized as Unix time (the number of seconds since the Unix epoch) """ @@ -2014,6 +2044,7 @@ type User """Phone number with international calling code.""" phone: Phone + supportChat: [SupportMessage!]! """Whether TOTP is enabled for this user.""" totpEnabled: Boolean! @@ -2156,7 +2187,6 @@ enum UserNotificationCategory { CIRCLES @join__enumValue(graph: GALOY) PAYMENTS @join__enumValue(graph: GALOY) - BALANCE @join__enumValue(graph: GALOY) ADMIN_NOTIFICATION @join__enumValue(graph: GALOY) MARKETING @join__enumValue(graph: GALOY) PRICE @join__enumValue(graph: GALOY) diff --git a/yarn.lock b/yarn.lock index d08042b67f..6292f49377 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15,10 +15,10 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" -"@apollo/client@^3.9.6": - version "3.9.6" - resolved "https://registry.npmjs.org/@apollo/client/-/client-3.9.6.tgz#4292448d9b0a48244a60307b74d2fea7e83dfe70" - integrity sha512-+zpddcnZ4G2VZ0xIEnvIHFsLqeopNOnWuE2ZVbRuetLLpj/biLPNN719B/iofdd1/iHRclKfv0XaAmX6PBhYKA== +"@apollo/client@^3.9.9": + version "3.9.9" + resolved "https://registry.npmjs.org/@apollo/client/-/client-3.9.9.tgz#38f983a1ad24e2687abfced0a9c1c3bef8d32616" + integrity sha512-/sMecU/M0WK9knrguts1lSLV8xFKzIgOMVb4mi6MOxgJXjliDB8PvOtmXhTqh2cVMMR4TzXgOnb+af/690zlQw== dependencies: "@graphql-typed-document-node/core" "^3.1.1" "@wry/caches" "^1.0.0" @@ -1926,6 +1926,14 @@ base64-js "^1.2.3" xmlbuilder "^14.0.0" +"@expo/react-native-action-sheet@^4.0.1": + version "4.0.1" + resolved "https://registry.npmjs.org/@expo/react-native-action-sheet/-/react-native-action-sheet-4.0.1.tgz#fa78e55a87a741f235be2c4ce0b0ea2b6afd06cf" + integrity sha512-FwCFpjpB6yzrK8CIWssLlh/i6zQFytFBiJfNdz0mJ2ckU4hWk8SrjB37P0Q4kF7w0bnIdYzPgRbdPR9hnfFqPw== + dependencies: + "@types/hoist-non-react-statics" "^3.3.1" + hoist-non-react-statics "^3.3.0" + "@expo/sdk-runtime-versions@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.0.tgz#d7ebd21b19f1c6b0395e50d78da4416941c57f7c" @@ -4354,6 +4362,16 @@ resolved "https://registry.npmjs.org/@rneui/themed/-/themed-4.0.0-rc.8.tgz#5c0e1aaa3d190ead88936693c5cef50ec404cd05" integrity sha512-8L/XOrL9OK/r+/iBLvx63TbIdZOXF8SIjN9eArMYm6kRbMr8m4BitXllDN8nBhBsSPNYvL6EAgjk+i2MfY4sBA== +"@ronradtke/react-native-markdown-display@^8.0.0": + version "8.0.0" + resolved "https://registry.npmjs.org/@ronradtke/react-native-markdown-display/-/react-native-markdown-display-8.0.0.tgz#ac2763290e19efed5d054fdb59c595af7b5edeea" + integrity sha512-i56CYXGXWDGN+dxF72dGiEn4Kld0L6c/JvcOrO4azX9YzVVl02F5EDgdb6fWUaiOl8gPqyUI7YIEU2OVGnIg6Q== + dependencies: + css-to-react-native "^3.2.0" + markdown-it "^13.0.1" + prop-types "^15.7.2" + react-native-fit-image "^1.5.5" + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.1" resolved "https://registry.npmjs.org/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.1.tgz#a21117b19ee9be70c379ec1877537ef2e1c63301" @@ -9820,6 +9838,15 @@ css-to-react-native@^2.2.1: css-color-keywords "^1.0.0" postcss-value-parser "^3.3.0" +css-to-react-native@^3.2.0: + version "3.2.0" + resolved "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz#cdd8099f71024e149e4f6fe17a7d46ecd55f1e32" + integrity sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ== + dependencies: + camelize "^1.0.0" + css-color-keywords "^1.0.0" + postcss-value-parser "^4.0.2" + css-tree@^1.1.3: version "1.1.3" resolved "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" @@ -10821,7 +10848,7 @@ entities@^2.0.0: resolved "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== -entities@^3.0.1: +entities@^3.0.1, entities@~3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4" integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q== @@ -15949,6 +15976,13 @@ lines-and-columns@^2.0.3: resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-2.0.4.tgz#d00318855905d2660d8c0822e3f5a4715855fc42" integrity sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A== +linkify-it@^4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz#01f1d5e508190d06669982ba31a7d9f56a5751ec" + integrity sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw== + dependencies: + uc.micro "^1.0.1" + listenercount@~1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937" @@ -16525,6 +16559,17 @@ markdown-extensions@^1.1.0: resolved "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-1.1.1.tgz#fea03b539faeaee9b4ef02a3769b455b189f7fc3" integrity sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q== +markdown-it@^13.0.1: + version "13.0.2" + resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.2.tgz#1bc22e23379a6952e5d56217fbed881e0c94d536" + integrity sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w== + dependencies: + argparse "^2.0.1" + entities "~3.0.1" + linkify-it "^4.0.1" + mdurl "^1.0.1" + uc.micro "^1.0.5" + marky@^1.2.2: version "1.2.5" resolved "https://registry.npmjs.org/marky/-/marky-1.2.5.tgz#55796b688cbd72390d2d399eaaf1832c9413e3c0" @@ -16634,7 +16679,7 @@ mdn-data@2.0.30: resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz#ce4df6f80af6cfbe218ecd5c552ba13c4dfa08cc" integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA== -mdurl@^1.0.0: +mdurl@^1.0.0, mdurl@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" integrity sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g== @@ -19071,7 +19116,7 @@ postcss-value-parser@^3.3.0: resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== -postcss-value-parser@^4.1.0: +postcss-value-parser@^4.0.2, postcss-value-parser@^4.1.0: version "4.2.0" resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== @@ -19797,6 +19842,13 @@ react-native-error-boundary@^1.2.3: version "6.0.0" resolved "git+https://github.com/hieuvp/react-native-fingerprint-scanner.git#9cecc0db326471c571553ea85f7c016fee2f803d" +react-native-fit-image@^1.5.5: + version "1.5.5" + resolved "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz#c660d1ad74b9dcaa1cba27a0d9c23837e000226c" + integrity sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg== + dependencies: + prop-types "^15.5.10" + react-native-gesture-handler@^2.14.0: version "2.14.0" resolved "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.14.0.tgz#d6aec0d8b2e55c67557fd6107e828c0a1a248be8" @@ -23124,6 +23176,11 @@ ua-parser-js@^1.0.35: resolved "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz#b5dc7b163a5c1f0c510b08446aed4da92c46373f" integrity sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ== +uc.micro@^1.0.1, uc.micro@^1.0.5: + version "1.0.6" + resolved "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac" + integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA== + uglify-es@^3.1.9: version "3.3.9" resolved "https://registry.npmjs.org/uglify-es/-/uglify-es-3.3.9.tgz#0c1c4f0700bed8dbc124cdb304d2592ca203e677"