From 231b3dbd7bacde4d9dab4828d406ca9f802a63e3 Mon Sep 17 00:00:00 2001 From: Michael Lustig Date: Tue, 3 Dec 2024 12:47:52 -0500 Subject: [PATCH] feat: add depdency management strategy --- assets/Encrypted.tsx | 4 +- components/Banner/AnimatedBanner.tsx | 8 +- components/Banner/Banner.tsx | 4 +- components/Chat/ChatNullState.tsx | 14 +- .../MessageReactions.store.tsx | 4 +- components/Chat/Message/MessageTail.tsx | 8 +- .../NewConversationButton.tsx | 2 + .../RequestsSegmentedController.tsx | 4 +- components/Drawer.tsx | 8 +- components/EmojiPicker/EmojiRow.tsx | 4 +- components/ErroredHeader.tsx | 4 +- .../useConnectViaWalletDisconnect.tsx | 2 +- components/Screen/ScreenComp/Screen.props.tsx | 16 +- components/Snackbar/Snackbar.store.ts | 4 +- components/Snackbar/Snackbar.tsx | 2 +- .../TransactionSimulationResult.tsx | 1 + data/store/accountsStore.ts | 39 +- data/store/authStore.ts | 4 +- data/store/settingsStore.ts | 4 +- dependencies/Environment/Environment.ts | 52 ++ .../Flavors/determineFlavor.utils.ts | 26 + .../Environment/Flavors/flavors.type.ts | 12 + dependencies/Environment/environment.todos.md | 5 + dependencies/NetworkMonitor/NetworkMonitor.ts | 381 +++++++++++++ dependencies/dependencies.readme.md | 104 ++++ .../BottomSheet/BottomSheetModal.tsx | 1 + design-system/Button/Button.props.ts | 8 +- design-system/TextField/TextField.props.tsx | 8 +- .../joinGroup/JoinGroup.client.ts | 526 ++++++++++++++++++ hooks/useAppStateHandlers.ts | 4 +- hooks/usePhotoSelect.ts | 4 +- hooks/usePreferredNames.ts | 1 + i18n/i18n.ts | 6 +- i18n/translate.ts | 1 - metro.config.js | 2 +- queries/entify.ts | 82 +++ queries/groupConsentMutationUtils.ts | 109 ++++ queries/useAllowGroupMutation.ts | 59 +- queries/useBlockGroupMutation.ts | 57 +- queries/useGroupInviteQuery.ts | 3 +- queries/useGroupsQuery.ts | 96 ++++ scripts/build/build.js | 2 +- scripts/build/update.js | 2 +- scripts/wasm.js | 2 +- tsconfig.json | 1 + utils/api.test.ts | 117 ++++ utils/api.ts | 14 +- utils/api.types.ts | 9 + utils/date.test.ts | 3 +- utils/emojis/interfaces.ts | 8 +- utils/evm/erc20.ts | 4 +- utils/evm/xmtp.ts | 1 + utils/groupUtils/adminUtils.test.ts | 4 +- utils/xmtpRN/client.types.ts | 50 ++ 54 files changed, 1794 insertions(+), 106 deletions(-) create mode 100644 dependencies/Environment/Environment.ts create mode 100644 dependencies/Environment/Flavors/determineFlavor.utils.ts create mode 100644 dependencies/Environment/Flavors/flavors.type.ts create mode 100644 dependencies/Environment/environment.todos.md create mode 100644 dependencies/NetworkMonitor/NetworkMonitor.ts create mode 100644 dependencies/dependencies.readme.md create mode 100644 features/GroupInvites/joinGroup/JoinGroup.client.ts create mode 100644 queries/groupConsentMutationUtils.ts create mode 100644 queries/useGroupsQuery.ts create mode 100644 utils/api.test.ts create mode 100644 utils/api.types.ts create mode 100644 utils/xmtpRN/client.types.ts diff --git a/assets/Encrypted.tsx b/assets/Encrypted.tsx index b4554572a..94f9dfc7f 100644 --- a/assets/Encrypted.tsx +++ b/assets/Encrypted.tsx @@ -1,11 +1,11 @@ import React from "react"; import Svg, { Path } from "react-native-svg"; -interface EncryptedProps { +type EncryptedProps = { width?: number; height?: number; color?: string; -} +}; const Encrypted: React.FC = ({ width = 40, diff --git a/components/Banner/AnimatedBanner.tsx b/components/Banner/AnimatedBanner.tsx index 45b379ec2..4da9d1176 100644 --- a/components/Banner/AnimatedBanner.tsx +++ b/components/Banner/AnimatedBanner.tsx @@ -11,14 +11,14 @@ import Reanimated, { import Banner from "./Banner"; -interface AnimatedBannerProps { +type AnimatedBannerProps = { title: string; description: string; cta?: string; onButtonPress?: () => void; onDismiss?: () => void; style?: StyleProp; -} +}; const VERTICAL_MARGIN = Margins.default; @@ -66,8 +66,8 @@ const AnimatedBanner: React.FC = React.memo((props) => { height: isAnimating ? height.value : measuredHeight.current !== null - ? height.value - : "auto", + ? height.value + : "auto", overflow: "hidden", })); diff --git a/components/Banner/Banner.tsx b/components/Banner/Banner.tsx index 0ed1d5e6c..d5d1e1873 100644 --- a/components/Banner/Banner.tsx +++ b/components/Banner/Banner.tsx @@ -19,7 +19,7 @@ import { Platform, } from "react-native"; -interface BannerProps { +type BannerProps = { title: string; description: string; cta?: string; @@ -29,7 +29,7 @@ interface BannerProps { onDismiss: () => void; style?: StyleProp; onLayout?: (event: LayoutChangeEvent) => void; -} +}; const Banner: React.FC = ({ title, diff --git a/components/Chat/ChatNullState.tsx b/components/Chat/ChatNullState.tsx index 1637f249b..b256177dc 100644 --- a/components/Chat/ChatNullState.tsx +++ b/components/Chat/ChatNullState.tsx @@ -1,5 +1,9 @@ import Recommendations from "@components/Recommendations/Recommendations"; -import { useSettingsStore } from "@data/store/accountsStore"; +import { + useSettingsStore, + useProfilesStore, + useRecommendationsStore, +} from "@data/store/accountsStore"; import { translate } from "@i18n/index"; import { backgroundColor, @@ -14,10 +18,6 @@ import React from "react"; import { Platform, StyleSheet, Text, useColorScheme, View } from "react-native"; import config from "../../config"; -import { - useProfilesStore, - useRecommendationsStore, -} from "../../data/store/accountsStore"; import { ShareProfileContent } from "../../screens/ShareProfile"; import { getPreferredAvatar, @@ -27,11 +27,11 @@ import { } from "../../utils/profile"; import NewConversationButton from "../ConversationList/NewConversationButton"; -interface ChatNullStateProps { +type ChatNullStateProps = { currentAccount: string; navigation: any; route: any; -} +}; const ChatNullState: React.FC = ({ currentAccount, diff --git a/components/Chat/Message/MessageReactions/MessageReactionsDrawer/MessageReactions.store.tsx b/components/Chat/Message/MessageReactions/MessageReactionsDrawer/MessageReactions.store.tsx index 6c878ec51..5eacc19d0 100644 --- a/components/Chat/Message/MessageReactions/MessageReactionsDrawer/MessageReactions.store.tsx +++ b/components/Chat/Message/MessageReactions/MessageReactionsDrawer/MessageReactions.store.tsx @@ -9,13 +9,13 @@ const initialMessageReactionsState: RolledUpReactions = { detailed: [], }; -export interface IMessageReactionsStore { +export type IMessageReactionsStore = { rolledUpReactions: RolledUpReactions; setRolledUpReactions: (reactions: RolledUpReactions) => void; // TODO: update state when new reactions come up and drawer is open // updateReactions: (updates: Partial) => void; -} +}; export const useMessageReactionsStore = create( (set) => ({ diff --git a/components/Chat/Message/MessageTail.tsx b/components/Chat/Message/MessageTail.tsx index f8df9f990..e42046593 100644 --- a/components/Chat/Message/MessageTail.tsx +++ b/components/Chat/Message/MessageTail.tsx @@ -20,12 +20,12 @@ class MessageTailComponent extends React.Component { const MessageTailAnimated = Reanimated.createAnimatedComponent(MessageTailComponent); -interface MessageTailProps { +type MessageTailProps = { fromMe: boolean; colorScheme: ColorSchemeName; hideBackground: boolean; style?: StyleProp>>; -} +}; const MessageTail: React.FC = ({ fromMe, @@ -40,8 +40,8 @@ const MessageTail: React.FC = ({ hideBackground ? "transparent" : fromMe - ? myMessageBubbleColor(colorScheme) - : messageBubbleColor(colorScheme) + ? myMessageBubbleColor(colorScheme) + : messageBubbleColor(colorScheme) } /> ); diff --git a/components/ConversationList/NewConversationButton.tsx b/components/ConversationList/NewConversationButton.tsx index f35dad5f1..05f11e2fe 100644 --- a/components/ConversationList/NewConversationButton.tsx +++ b/components/ConversationList/NewConversationButton.tsx @@ -10,9 +10,11 @@ import Picto from "../Picto/Picto"; export default function NewConversationButton() { const colorScheme = useColorScheme(); + const onPress = useCallback(() => { navigate("NewConversation"); }, []); + const showDebug = useCallback(() => { converseEventEmitter.emit("showDebugMenu"); }, []); diff --git a/components/ConversationList/RequestsSegmentedController.tsx b/components/ConversationList/RequestsSegmentedController.tsx index 6d44f9a4d..1efc4180b 100644 --- a/components/ConversationList/RequestsSegmentedController.tsx +++ b/components/ConversationList/RequestsSegmentedController.tsx @@ -12,11 +12,11 @@ import { useColorScheme, } from "react-native"; -interface SegmentedControllerProps { +type SegmentedControllerProps = { options: string[]; selectedIndex: number; onSelect: (index: number) => void; -} +}; const RequestsSegmentedController: React.FC = ({ options, diff --git a/components/Drawer.tsx b/components/Drawer.tsx index 097ca763f..a0accb162 100644 --- a/components/Drawer.tsx +++ b/components/Drawer.tsx @@ -38,13 +38,13 @@ const DRAWER_ANIMATION_DURATION = 300; const DRAWER_THRESHOLD = 100; const TRANSLATION = 1000; -export interface DrawerProps { +export type DrawerProps = { visible: boolean; children: React.ReactNode; onClose?: () => void; style?: ViewStyle; showHandle?: boolean; -} +}; export const DrawerContext = React.createContext<{ closeDrawer: () => void; @@ -52,13 +52,13 @@ export const DrawerContext = React.createContext<{ closeDrawer: () => {}, }); -export interface DrawerRef { +export type DrawerRef = { /** * Will tell the drawer to close, but still needs * @returns */ closeDrawer: (callback: () => void) => void; -} +}; export const Drawer = forwardRef(function Drawer( { children, visible, onClose, style, showHandle }, diff --git a/components/EmojiPicker/EmojiRow.tsx b/components/EmojiPicker/EmojiRow.tsx index a4a97cd6c..20f3657c5 100644 --- a/components/EmojiPicker/EmojiRow.tsx +++ b/components/EmojiPicker/EmojiRow.tsx @@ -2,10 +2,10 @@ import { CategorizedEmojisRecord, Emoji } from "@utils/emojis/interfaces"; import { FC, memo, useMemo } from "react"; import { Platform, Pressable, StyleSheet, Text, View } from "react-native"; -interface EmojiRowProps { +type EmojiRowProps = { item: CategorizedEmojisRecord; onPress: (emoji: string) => void; -} +}; export const EmojiRow: FC = memo(({ item, onPress }) => { const items = useMemo(() => { diff --git a/components/ErroredHeader.tsx b/components/ErroredHeader.tsx index 172c36561..f787b1fa8 100644 --- a/components/ErroredHeader.tsx +++ b/components/ErroredHeader.tsx @@ -5,9 +5,9 @@ import { StyleSheet, useColorScheme, View, ViewStyle } from "react-native"; import Picto from "./Picto/Picto"; -interface ErroredHeaderProps { +type ErroredHeaderProps = { style?: ViewStyle; -} +}; export const ErroredHeader: FC = ({ style }) => { const colorScheme = useColorScheme(); diff --git a/components/Onboarding/ConnectViaWallet/useConnectViaWalletDisconnect.tsx b/components/Onboarding/ConnectViaWallet/useConnectViaWalletDisconnect.tsx index 485f75f08..2cdf40d6c 100644 --- a/components/Onboarding/ConnectViaWallet/useConnectViaWalletDisconnect.tsx +++ b/components/Onboarding/ConnectViaWallet/useConnectViaWalletDisconnect.tsx @@ -31,7 +31,7 @@ export function useConnectViaWalletDisconnect() { logger.debug("[Connect Wallet] Disconnected"); }, - // eslint-disable-next-line react-hooks/exhaustive-deps + [thirdwebWallet] ); } diff --git a/components/Screen/ScreenComp/Screen.props.tsx b/components/Screen/ScreenComp/Screen.props.tsx index c1af044f6..fefdb634c 100644 --- a/components/Screen/ScreenComp/Screen.props.tsx +++ b/components/Screen/ScreenComp/Screen.props.tsx @@ -3,7 +3,7 @@ import { ScrollViewProps, StyleProp, ViewStyle } from "react-native"; import { ExtendedEdge } from "./Screen.helpers"; -interface BaseScreenProps { +type BaseScreenProps = { /** * Children components. */ @@ -40,13 +40,13 @@ interface BaseScreenProps { * By how much should we offset the keyboard? Defaults to 0. */ keyboardOffset?: number; -} +}; -export interface FixedScreenProps extends BaseScreenProps { +export type FixedScreenProps = { preset?: "fixed"; -} +} & BaseScreenProps; -export interface ScrollScreenProps extends BaseScreenProps { +export type ScrollScreenProps = { preset?: "scroll"; /** * Should keyboard persist on screen tap. Defaults to handled. @@ -57,16 +57,16 @@ export interface ScrollScreenProps extends BaseScreenProps { * Pass any additional props directly to the ScrollView component. */ ScrollViewProps?: ScrollViewProps; -} +} & BaseScreenProps; -export interface AutoScreenProps extends Omit { +export type AutoScreenProps = { preset?: "auto"; /** * Threshold to trigger the automatic disabling/enabling of scroll ability. * Defaults to `{ percent: 0.92 }`. */ scrollEnabledToggleThreshold?: { percent?: number; point?: number }; -} +} & Omit; export type IScreenProps = | ScrollScreenProps diff --git a/components/Snackbar/Snackbar.store.ts b/components/Snackbar/Snackbar.store.ts index 6405f5adb..a448df5f4 100644 --- a/components/Snackbar/Snackbar.store.ts +++ b/components/Snackbar/Snackbar.store.ts @@ -2,11 +2,11 @@ import { ISnackbar } from "@components/Snackbar/Snackbar.types"; import { create } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; -export interface ISnackBarStore { +export type ISnackBarStore = { snackbars: ISnackbar[]; showSnackbar: (snackbar: ISnackbar) => void; clearAllSnackbars: () => void; -} +}; export const useSnackBarStore = create()( subscribeWithSelector((set) => ({ diff --git a/components/Snackbar/Snackbar.tsx b/components/Snackbar/Snackbar.tsx index 702ed9cbf..7190fbe4d 100644 --- a/components/Snackbar/Snackbar.tsx +++ b/components/Snackbar/Snackbar.tsx @@ -3,6 +3,7 @@ import { SNACKBAR_BOTTOM_OFFSET, SNACKBAR_HEIGHT, SNACKBAR_LARGE_TEXT_HEIGHT, + SNACKBAR_SPACE_BETWEEN_SNACKBARS, } from "@components/Snackbar/Snackbar.constants"; import { getNumberOfSnackbars, @@ -28,7 +29,6 @@ import { withSpring, withTiming, } from "react-native-reanimated"; -import { SNACKBAR_SPACE_BETWEEN_SNACKBARS } from "./Snackbar.constants"; type SnackbarProps = { snackbar: ISnackbar; diff --git a/components/TransactionPreview/TransactionSimulationResult.tsx b/components/TransactionPreview/TransactionSimulationResult.tsx index ca743bdf0..62f456ad3 100644 --- a/components/TransactionPreview/TransactionSimulationResult.tsx +++ b/components/TransactionPreview/TransactionSimulationResult.tsx @@ -24,6 +24,7 @@ export const SimulationResult = ({ }: SimulationResultProps) => { const profiles = useProfilesStore((s) => s.profiles); const accountAddress = useCurrentAccount() as string; + const myChanges = useMemo(() => { const myAddresses = [accountAddress.toLowerCase()]; if (walletAddress) { diff --git a/data/store/accountsStore.ts b/data/store/accountsStore.ts index 2a4289b3f..59a26c309 100644 --- a/data/store/accountsStore.ts +++ b/data/store/accountsStore.ts @@ -9,13 +9,17 @@ import { initRecommendationsStore, RecommendationsStoreType, } from "./recommendationsStore"; -import { initSettingsStore, SettingsStoreType } from "./settingsStore"; +import { + GroupStatus, + initSettingsStore, + SettingsStoreType, +} from "./settingsStore"; import { initTransactionsStore, TransactionsStoreType, } from "./transactionsStore"; import { initWalletStore, WalletStoreType } from "./walletStore"; -import { removeLogoutTask } from "../../utils/logout"; +import { removeLogoutTask } from "@utils/logout"; import mmkv, { zustandMMKVStorage } from "../../utils/mmkv"; type AccountStoreType = { @@ -230,8 +234,37 @@ const getAccountStore = (account: string) => { } }; -export const currentAccount = () => useAccountsStore.getState().currentAccount; +export const currentAccount = (): string => + (useAccountsStore.getState() as AccountsStoreStype).currentAccount; + +// +/** + * TODO: determine if this is the way we want to transition to imperatively + * calling our Zustand store. + * TODO: move this to a different file + * + * It isn't very ergonomic and mocking things seems a little difficult. + * + * We might want to look into creating a subscription to our stores and a + * behavior subject by which to observe it across the app. + * + * Set the group status for the current account + * @param groupStatus The group status to set + */ +export const setGroupStatus = (groupStatus: GroupStatus) => { + const account = currentAccount(); + if (!account) { + logger.warn("[setGroupStatus] No current account"); + return; + } + const setGroupStatus = getSettingsStore(account).getState().setGroupStatus; + setGroupStatus(groupStatus); +}; +// we'll be able to create a subscription to our stores and a behavior subject +// by which to observe it across the app +// We'll seed the behaviorsubject with the getState.value api +// export const _currentAccount = () => useAccountsStore.subscribe((s) => s.); export const useCurrentAccount = () => { const currentAccount = useAccountsStore((s) => s.currentAccount); return currentAccount === TEMPORARY_ACCOUNT_NAME ? undefined : currentAccount; diff --git a/data/store/authStore.ts b/data/store/authStore.ts index cc9c1f846..dc8bc8183 100644 --- a/data/store/authStore.ts +++ b/data/store/authStore.ts @@ -3,9 +3,9 @@ import { subscribeWithSelector } from "zustand/middleware"; export type IAuthStatus = "idle" | "signedOut" | "signedIn"; -export interface IAuthStore { +export type IAuthStore = { status: IAuthStatus; -} +}; export const useAuthStore = create()( subscribeWithSelector((set, get) => ({ diff --git a/data/store/settingsStore.ts b/data/store/settingsStore.ts index 5658ef70b..b8fd101f7 100644 --- a/data/store/settingsStore.ts +++ b/data/store/settingsStore.ts @@ -34,9 +34,7 @@ export type SettingsStoreType = { }) => void; groupStatus: GroupStatus; - setGroupStatus: (groupStatus: { - [groupId: string]: "allowed" | "denied"; - }) => void; + setGroupStatus: (groupStatus: GroupStatus) => void; ephemeralAccount: boolean; setEphemeralAccount: (ephemeral: boolean) => void; diff --git a/dependencies/Environment/Environment.ts b/dependencies/Environment/Environment.ts new file mode 100644 index 000000000..2644e8c41 --- /dev/null +++ b/dependencies/Environment/Environment.ts @@ -0,0 +1,52 @@ +import api from "@utils/api"; + +import { JoinGroupClient } from "../../features/GroupInvites/joinGroup/JoinGroup.client"; + +export type Environment = { + joinGroupClient: JoinGroupClient; +}; + +export const LiveEnvironment = (): Environment => ({ + joinGroupClient: JoinGroupClient.live({ api }), +}); + +export const QaEnvironment = (): Environment => ({ + joinGroupClient: JoinGroupClient.live({ api }), +}); + +export const UnimplementedEnvironment: Environment = { + joinGroupClient: JoinGroupClient.unimplemented(), +}; + +const isTest = process.env.NODE_ENV === "test"; +const isLowerEnvBuild = false; + +const getEnvironmentForFlavor = (): Environment => { + let result: Environment = UnimplementedEnvironment; + + if (isLowerEnvBuild) { + result = QaEnvironment(); + } else if (!isTest) { + result = LiveEnvironment(); + } + + return result; +}; + +const EnvironmentForFlavor: Environment = getEnvironmentForFlavor(); + +/** + * The current environment instance. + * This is the main export that should be used throughout the application to + * access dependencies. + * + * @example + * import { Controlled } from './Environment'; + * + * function observeNetworkState() { + * Controlled.networkMonitorClient.subscribe(state => { + * console.log('Network state:', state.status); + * }); + * } + */ +export const Controlled: Environment = EnvironmentForFlavor; diff --git a/dependencies/Environment/Flavors/determineFlavor.utils.ts b/dependencies/Environment/Flavors/determineFlavor.utils.ts new file mode 100644 index 000000000..7c33358db --- /dev/null +++ b/dependencies/Environment/Flavors/determineFlavor.utils.ts @@ -0,0 +1,26 @@ +import { DependencyFlavor, DependencyFlavors } from "./flavors.type"; + +export function determineDependencyFlavor(): DependencyFlavor { + if (typeof jest !== "undefined" || process.env.JEST_WORKER_ID !== undefined) { + return DependencyFlavors.jest; + } + + try { + const { Platform } = require("react-native"); + + if (Platform.OS === "ios") { + return Platform.constants.interfaceIdiom === "simulator" + ? DependencyFlavors.iosSimulator + : DependencyFlavors.iosDevice; + } else if (Platform.OS === "android") { + return Platform.constants.Brand === "google" && + Platform.constants.Model.includes("sdk") + ? DependencyFlavors.androidEmulator + : DependencyFlavors.androidDevice; + } + } catch (error) { + console.error("Error determining platform:", error); + } + + return DependencyFlavors.Unknown; +} diff --git a/dependencies/Environment/Flavors/flavors.type.ts b/dependencies/Environment/Flavors/flavors.type.ts new file mode 100644 index 000000000..6b02bbede --- /dev/null +++ b/dependencies/Environment/Flavors/flavors.type.ts @@ -0,0 +1,12 @@ +export const DependencyFlavors = { + iosSimulator: "iOS Simulator", + iosDevice: "iOS Device", + androidEmulator: "Android Emulator", + androidDevice: "Android Device", + jest: "Jest Test Environment", + detox: "Detox Test Environment", + Unknown: "Unknown", +} as const; + +export type DependencyFlavor = + (typeof DependencyFlavors)[keyof typeof DependencyFlavors]; diff --git a/dependencies/Environment/environment.todos.md b/dependencies/Environment/environment.todos.md new file mode 100644 index 000000000..6f01608b1 --- /dev/null +++ b/dependencies/Environment/environment.todos.md @@ -0,0 +1,5 @@ +# TODOs for Building our Environment + +- [ ] More robust flavor detection +- [ ] More ergonomic testing + - [ ] easy deplendency override and reset diff --git a/dependencies/NetworkMonitor/NetworkMonitor.ts b/dependencies/NetworkMonitor/NetworkMonitor.ts new file mode 100644 index 000000000..65fea3f4f --- /dev/null +++ b/dependencies/NetworkMonitor/NetworkMonitor.ts @@ -0,0 +1,381 @@ +import NetInfo from "@react-native-community/netinfo"; +import { + BehaviorSubject, + Observable, + Subscription, + PartialObserver, +} from "rxjs"; +import { switchMap, shareReplay, distinctUntilChanged } from "rxjs/operators"; + +/* + * NetworkMonitorClient provides a flexible API to monitor + * network availability. It allows for dynamic control of + * the network dependency at runtime, whether in tests or + * live environments. Subscribers do not need to know about + * the underlying implementation, allowing developers to + * change the functionality easily as needed. + * + * Example usage: + * + * // In production + * const networkMonitor = NetworkMonitorClient.live(); + * + * // In tests + * const networkMonitor = NetworkMonitorClient.satisfied(); + * + * Subscribers can subscribe to network state changes without + * worrying about the underlying implementation. + */ + +/* + * Represents the current state of network availability. + * + * Possible statuses: + * - 'satisfied': Network is available. + * - 'unsatisfied': Network is unavailable. + * - 'unknown': Network state is unknown. + */ +export type NetworkAvailability = { + status: "satisfied" | "unsatisfied" | "unknown"; +}; + +/* + * NetworkMonitorClient provides an interface to monitor + * and control the network state. It uses RxJS Observables + * to allow subscribers to react to network state changes. + * + * This class supports different implementations for various + * scenarios such as live monitoring, fixed states for testing, + * and custom behaviors. By abstracting the underlying + * implementation, subscribers can remain agnostic to how the + * network state is provided. + * + * The network state can be dynamically controlled at runtime, + * which is useful for testing or simulating network conditions. + * + * This class uses the singleton pattern to ensure that only + * one instance of NetworkMonitorClient exists throughout the + * application. This is important because it maintains a single + * source of truth for the network state and allows for consistent + * behavior across different parts of the application. + */ +export class NetworkMonitorClient { + /* + * Singleton instance of NetworkMonitorClient. + * Ensures that only one instance exists throughout + * the application. + */ + private static instance: NetworkMonitorClient; + + /* + * Subject that holds the current Observable. + * Allows dynamic switching of the network observable at runtime. + */ + private networkObservableSubject: BehaviorSubject< + Observable + >; + + /* + * Observable that emits the network availability status. + * Subscribers can subscribe to this Observable to get updates. + */ + private networkObservable: Observable; + + /* + * Private constructor to enforce singleton pattern. + * Initializes the network observable with the given + * initial observable. + * + * @param initialObservable - Initial Observable + */ + private constructor(initialObservable: Observable) { + this.networkObservableSubject = new BehaviorSubject(initialObservable); + + /* + * The networkObservable is built by piping the + * networkObservableSubject through several RxJS + * operators: + * + * 1. switchMap: + * - Flattens the higher-order observable (observable of observables) + * by switching to the latest emitted observable. + * - Ensures that subscribers receive values from the + * most recent observable provided to the subject. + * + * 2. distinctUntilChanged: + * - Prevents emitting duplicate consecutive network statuses. + * - Only emits when the network status changes. + * + * 3. shareReplay(1): + * - Shares the observable among multiple subscribers. + * - Replays the last emitted value to new subscribers. + * - Ensures that all subscribers receive the latest network status + * without causing multiple subscriptions to the source observable. + */ + this.networkObservable = this.networkObservableSubject.pipe( + switchMap((observable) => observable), + distinctUntilChanged((a, b) => a.status === b.status), + shareReplay(1) + ); + } + + /* + * Gets the singleton instance of NetworkMonitorClient. + * If no instance exists, it initializes one with an + * unimplemented observable to catch unintended usage. + * + * This method is kept private to enforce the use of + * predefined static methods like live(), satisfied(), + * etc., for obtaining the instance. + * + * @returns The singleton instance of NetworkMonitorClient. + */ + private static getInstance(): NetworkMonitorClient { + if (!NetworkMonitorClient.instance) { + // Default to unimplemented to catch any unintended usage + NetworkMonitorClient.instance = new NetworkMonitorClient( + NetworkMonitorClient.unimplementedObservable() + ); + } + return NetworkMonitorClient.instance; + } + + /* + * Subscribes to network state changes. + * + * This method supports both observer objects and + * next callback functions. + * + * @param observer - An observer object with next, error, and complete callbacks. + * @returns A subscription object that can be used to unsubscribe. + * + * Example: + * + * networkMonitor.subscribe({ + * next: (networkAvailability) => { + * console.log(networkAvailability.status); + * }, + * error: (error) => { + * console.error(error); + * }, + * complete: () => { + * console.log('Subscription complete'); + * } + * }); + * + * Or using a next callback: + * + * networkMonitor.subscribe((networkAvailability) => { + * console.log(networkAvailability.status); + * }); + */ + subscribe(observer: PartialObserver): Subscription; + subscribe(next: (value: NetworkAvailability) => void): Subscription; + subscribe( + observerOrNext: + | PartialObserver + | ((value: NetworkAvailability) => void) + ): Subscription { + if (typeof observerOrNext === "function") { + return this.networkObservable.subscribe(observerOrNext); + } + return this.networkObservable.subscribe(observerOrNext); + } + + /* + * Sets the internal observable to a new one. + * Allows dynamic switching of the network observable. + * + * @param observable - The new Observable to switch to. + */ + private setNetworkObservable(observable: Observable) { + this.networkObservableSubject.next(observable); + } + + /* + * Creates a NetworkMonitorClient that monitors real + * network state changes using NetInfo from + * '@react-native-community/netinfo'. + * + * This is typically used in production environments. + * + * Marble Diagram: + * + * [Network State Changes] ----> (satisfied) ----> (unsatisfied) ----> ... + * + * Example: + * + * const networkMonitor = NetworkMonitorClient.live(); + * networkMonitor.subscribe((state) => { + * console.log(state.status); + * }); + */ + static live(): NetworkMonitorClient { + const liveObservable = new Observable((subscriber) => { + const fetchAndUpdateNetworkState = () => { + NetInfo.fetch().then((state) => { + const networkState: NetworkAvailability = { + status: state.isConnected ? "satisfied" : "unsatisfied", + }; + subscriber.next(networkState); + }); + }; + + fetchAndUpdateNetworkState(); + + const unsubscribeNetInfo = NetInfo.addEventListener((state) => { + const networkState: NetworkAvailability = { + status: state.isConnected ? "satisfied" : "unsatisfied", + }; + subscriber.next(networkState); + }); + + return () => { + unsubscribeNetInfo(); + }; + }).pipe( + distinctUntilChanged((a, b) => a.status === b.status), + shareReplay(1) + ); + + NetworkMonitorClient.getInstance().setNetworkObservable(liveObservable); + return NetworkMonitorClient.getInstance(); + } + + /* + * Creates a NetworkMonitorClient that always emits + * a 'satisfied' network state. + * + * Useful for testing scenarios where a stable network + * connection is assumed. + * + * Marble Diagram: + * + * (satisfied) ----> (satisfied) ----> (satisfied) ----> ... + * + * Example: + * + * const networkMonitor = NetworkMonitorClient.satisfied(); + */ + static satisfied(): NetworkMonitorClient { + const satisfiedObservable = new BehaviorSubject({ + status: "satisfied", + }); + + NetworkMonitorClient.getInstance().setNetworkObservable( + satisfiedObservable + ); + return NetworkMonitorClient.getInstance(); + } + + /* + * Creates a NetworkMonitorClient that always emits + * an 'unsatisfied' network state. + * + * Useful for testing scenarios where no network + * connection is available. + * + * Marble Diagram: + * + * (unsatisfied) ----> (unsatisfied) ----> (unsatisfied) ----> ... + * + * Example: + * + * const networkMonitor = NetworkMonitorClient.unsatisfied(); + */ + static unsatisfied(): NetworkMonitorClient { + const unsatisfiedObservable = new BehaviorSubject({ + status: "unsatisfied", + }); + + NetworkMonitorClient.getInstance().setNetworkObservable( + unsatisfiedObservable + ); + return NetworkMonitorClient.getInstance(); + } + + /* + * Creates a NetworkMonitorClient that alternates between + * 'satisfied' and 'unsatisfied' states every 2 seconds. + * + * Useful for testing scenarios with unstable network conditions. + * + * Marble Diagram: + * + * (satisfied) ----2s----> (unsatisfied) ----2s----> (satisfied) ----> ... + * + * Example: + * + * const networkMonitor = NetworkMonitorClient.flakey(); + */ + static flakey(): NetworkMonitorClient { + const subject = new BehaviorSubject({ + status: "satisfied", + }); + let isSatisfied = true; + + setInterval(() => { + isSatisfied = !isSatisfied; + subject.next({ status: isSatisfied ? "satisfied" : "unsatisfied" }); + }, 2000); + + NetworkMonitorClient.getInstance().setNetworkObservable(subject); + return NetworkMonitorClient.getInstance(); + } + + /* + * Creates a NetworkMonitorClient with custom behavior. + * Useful for complex testing scenarios or simulations. + * + * @param observable - A custom Observable + * + * Example: + * + * const customObservable = new Observable((subscriber) => { + * // Custom logic here + * }); + * + * const networkMonitor = NetworkMonitorClient.custom(customObservable); + */ + static custom( + observable: Observable + ): NetworkMonitorClient { + NetworkMonitorClient.getInstance().setNetworkObservable(observable); + return NetworkMonitorClient.getInstance(); + } + + /* + * Creates a NetworkMonitorClient that throws an error + * when used. Useful for ensuring that tests explicitly + * provide implementations for all dependencies. + * + * Example: + * + * const networkMonitor = NetworkMonitorClient.unimplemented(); + */ + static unimplemented(): NetworkMonitorClient { + const unimplementedObservable = + NetworkMonitorClient.unimplementedObservable(); + NetworkMonitorClient.getInstance().setNetworkObservable( + unimplementedObservable + ); + return NetworkMonitorClient.getInstance(); + } + + /* + * Internal method to create an unimplemented observable. + * Emits an error when subscribed to. + */ + private static unimplementedObservable(): Observable { + return new Observable((subscriber) => { + subscriber.error( + new Error( + "[NetworkMonitor] ERROR: unimplemented - Your code has subscribed to NetworkMonitor " + + "without specifying an implementation. This unimplemented dependency is here to " + + "ensure you don't invoke code you don't intend to, ensuring your tests are truly " + + "testing what they are expected to" + ) + ); + }).pipe(shareReplay(1)); + } +} diff --git a/dependencies/dependencies.readme.md b/dependencies/dependencies.readme.md new file mode 100644 index 000000000..a01baf1c7 --- /dev/null +++ b/dependencies/dependencies.readme.md @@ -0,0 +1,104 @@ +# Dependency Management Pattern: Controlled Dependencies + +## Table of Contents + +1. [Introduction](#introduction) +2. [How It Works](#how-it-works) +3. [Step-by-Step Guide](#step-by-step-guide-controlling-your-dependencies) +4. [Benefits & Trade-offs](#benefits--trade-offs) +5. [Philosophy](#philosophy-why-mocking-isnt-always-the-answer) +6. [Real-World Usage](#real-world-usage) +7. [Key Concepts](#key-concepts) + +## Introduction + +This pattern consolidates complex behaviors into a global +singleton object, enhancing testability, flexibility, and +maintainability. + +## How It Works + +We use a central `Controlled` object to hold major +dependencies: + + export let Controlled: Environment = { + date: DateClient.live(), + networkMonitorClient: NetworkMonitorClient.live(), + appStateClient: AppStateClient.live(), + // ... other dependencies + }; + +Each dependency has multiple implementations: + +- `live()`: Real-world implementation +- `unimplemented()`: Throws an error if used unexpectedly + in tests +- Various mock implementations for testing/QA + +### Dependency Matrix + +| Flavor | Platform | NetworkMonitorClient | +| ------- | ---------------- | -------------------- | +| Dev | iOS Simulator | Satisfied | +| QA | iOS Simulator | Satisfied | +| Dev | Android Emulator | Satisfied | +| QA | Android Emulator | Satisfied | +| Dev | iOS Device | Live | +| QA | iOS Device | Live | +| Dev | Android Device | Live | +| QA | Android Device | Live | +| Prod | iOS Device | Live | +| Prod | Android Device | Live | +| Jest | N/A | Unimplemented | +| Detox | iOS Simulator | Live | +| Detox | Android Emulator | Live | +| Unknown | Any | Unimplemented | + +## Step-by-Step Guide: Controlling Your Dependencies + +1. Identify the dependency +2. Create a client +3. Create a live implementation +4. Create static instances +5. Create a custom implementation +6. Create an unimplemented version +7. Add to Environment + +## Benefits & Trade-offs + +Benefits: + +- Centralized control +- Enhanced testability +- Flexibility +- Reduced mocking +- Consistency + +Trade-offs: + +- Global singleton (mitigated by avoiding state in + dependencies) +- Potential for misuse (mitigated by future lint rules) +- Learning curve (mitigated by documentation and + education) + +## Philosophy: Why Mocking Isn't Always the Answer + +Our pattern focuses on controlling dependencies, aligning +more closely with production code. This allows for: + +- Testing with near-production configurations +- Easy simulation of different environments +- Avoiding complexity and inaccuracies of mocks + +## Real-World Usage + +Search for `Controlled.` in the codebase to see where +this +pattern is used. + +## Key Concepts + +- Dependency Injection +- Singleton +- Observable Pattern diff --git a/design-system/BottomSheet/BottomSheetModal.tsx b/design-system/BottomSheet/BottomSheetModal.tsx index e74b1ef16..7e649df2d 100644 --- a/design-system/BottomSheet/BottomSheetModal.tsx +++ b/design-system/BottomSheet/BottomSheetModal.tsx @@ -32,6 +32,7 @@ export const BottomSheetModal = memo( const { theme } = useAppTheme(); // https://github.com/gorhom/react-native-bottom-sheet/issues/1644#issuecomment-1949019839 + const renderContainerComponent = useCallback((props: any) => { return ; }, []); diff --git a/design-system/Button/Button.props.ts b/design-system/Button/Button.props.ts index b88fedaa0..1c2db1ded 100644 --- a/design-system/Button/Button.props.ts +++ b/design-system/Button/Button.props.ts @@ -23,13 +23,13 @@ export type IButtonSize = "sm" | "md" | "lg"; export type IButtonAction = "primary" | "danger"; -export interface IButtonAccessoryProps { +export type IButtonAccessoryProps = { style: StyleProp; pressableState: PressableStateCallbackType; disabled?: boolean; -} +}; -export interface IButtonProps extends RNPressableProps { +export type IButtonProps = { /** * Text which is looked up via i18n. */ @@ -115,4 +115,4 @@ export interface IButtonProps extends RNPressableProps { title?: string; /** @deprecated use icon instead */ picto?: IIconName; -} +} & RNPressableProps; diff --git a/design-system/TextField/TextField.props.tsx b/design-system/TextField/TextField.props.tsx index 7cc544e07..80072a137 100644 --- a/design-system/TextField/TextField.props.tsx +++ b/design-system/TextField/TextField.props.tsx @@ -10,14 +10,14 @@ import { import { ITextProps } from "../Text/Text.props"; -export interface TextFieldAccessoryProps { +export type TextFieldAccessoryProps = { style: StyleProp; status: TextFieldProps["status"]; multiline: boolean; editable: boolean; -} +}; -export interface TextFieldProps extends Omit { +export type TextFieldProps = { /** * A style modifier for different input states. */ @@ -93,4 +93,4 @@ export interface TextFieldProps extends Omit { * Note: It is a good idea to memoize this. */ LeftAccessory?: ComponentType; -} +} & Omit; diff --git a/features/GroupInvites/joinGroup/JoinGroup.client.ts b/features/GroupInvites/joinGroup/JoinGroup.client.ts new file mode 100644 index 000000000..08c3452db --- /dev/null +++ b/features/GroupInvites/joinGroup/JoinGroup.client.ts @@ -0,0 +1,526 @@ +import { OnConsentOptions } from "@hooks/useGroupConsent"; +import { createGroupJoinRequest, getGroupJoinRequest } from "@utils/api"; +import { GroupInvite } from "@utils/api.types"; +import { getGroupIdFromTopic } from "@utils/groupUtils/groupId"; +import logger from "@utils/logger"; +import { GroupData, GroupsDataEntity } from "@utils/xmtpRN/client.types"; +import { InboxId } from "@xmtp/react-native-sdk"; +import { AxiosInstance } from "axios"; + +import {} from "../groupInvites.utils"; +import { JoinGroupResult } from "./joinGroup.types"; + +const GROUP_JOIN_REQUEST_POLL_MAX_ATTEMPTS = 10; +const GROUP_JOIN_REQUEST_POLL_INTERVAL_MS = 1000; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +/** + * TODOs: + * + * determine at what point in this client we want to implmeent queryClient + * options: + * 1) in base client type so that all flavors behave the same + * - I'm leaning towards this + * 2) decided per flavor, so that we can have a live client that uses the query + * client and a mock client that doesn't + * + * Naming Conventions: + * + * Fetch: fetches data from the server or over the network somehow + * Create: creates data on the server or over the network somehow + * Get: gets data from some local cache or storage + * Save: saves data to some local cache or storage + */ + +export type AllowGroupProps = { + account: string; + group: GroupData; + options: OnConsentOptions; +}; + +export class JoinGroupClient { + fetchGroupInvite: (groupInviteId: string) => Promise; + attemptToJoinGroup: ( + account: string, + groupInviteId: string + ) => Promise; + fetchGroupsByAccount: (account: string) => Promise; + allowGroup: (props: AllowGroupProps) => Promise; + refreshGroup: (account: string, topic: string) => Promise; + + constructor( + fetchGroupInvite: (groupInviteId: string) => Promise, + attemptToJoinGroup: ( + account: string, + groupInviteId: string + ) => Promise, + fetchGroupsByAccount: (account: string) => Promise, + allowGroup: (props: AllowGroupProps) => Promise, + refreshGroup: (account: string, topic: string) => Promise + ) { + this.fetchGroupInvite = fetchGroupInvite; + this.attemptToJoinGroup = attemptToJoinGroup; + this.fetchGroupsByAccount = fetchGroupsByAccount; + this.allowGroup = allowGroup; + this.refreshGroup = refreshGroup; + } + + static live({ api }: { api: AxiosInstance }): JoinGroupClient { + const liveGetGroupInvite = async ( + groupInviteId: string + ): Promise => { + const { data } = await api.get(`/api/groupInvite/${groupInviteId}`); + return data as GroupInvite; + }; + + const liveFetchGroupsByAccount = async ( + account: string + ): Promise => { + const { fetchGroupsQuery } = await import("@queries/useGroupsQuery"); + const groupsEntity: GroupsDataEntity = await fetchGroupsQuery(account); + const cleanedGroupsEntity = { + byId: Object.fromEntries( + Object.entries(groupsEntity.byId).map(([id, group]) => [ + [group.id], + { ...group, client: undefined }, + ]) + ), + ids: Object.values(groupsEntity.byId).map((group) => group.id), + }; + + return cleanedGroupsEntity; + }; + + /** + * TODO: add sdk streaming and race promises + * @param account + * @param groupInviteId + * @param groupIdFromInvite + */ + /** + * Attempts to join a group using the provided account and invite details. + * + * This function handles the process of joining a group, including + * creating a join request if necessary, and polling for the request status. + * + * @param {string} account - The account attempting to join the group + * @param {string} groupInviteId - The ID of the group invite + * @param {string} [groupIdFromInvite] - Optional group ID from the invite + * @returns {Promise} The result of the join attempt + * + * @example + * // Attempt to join a group + * const result = await liveAttemptToJoinGroup('user123', 'invite456') + * // Returns: { type: 'group-join-request.accepted', groupId: 'group789' } + * + * @example + * // Attempt to join with known group ID + * const result = await liveAttemptToJoinGroup('user123', 'invite456', 'group789') + * // Returns: { type: 'group-join-request.rejected' } + */ + const liveAttemptToJoinGroup = async ( + account: string, + groupInviteId: string, + groupIdFromInvite?: string + ): Promise => { + logger.debug( + `[liveAttemptToJoinGroup] Starting join attempt for account: ${account}, groupInviteId: ${groupInviteId}` + ); + + const groupsBeforeJoining = groupIdFromInvite + ? { ids: [], byId: {} } + : await liveFetchGroupsByAccount(account); + logger.debug( + `[liveAttemptToJoinGroup] Before joining, group count = ${groupsBeforeJoining.ids.length}` + ); + + const joinRequest = await createGroupJoinRequest(account, groupInviteId); + const joinRequestId = joinRequest.id; + + let attemptsToRetryJoinGroup = 0; + while (attemptsToRetryJoinGroup < GROUP_JOIN_REQUEST_POLL_MAX_ATTEMPTS) { + logger.debug( + `[liveAttemptToJoinGroup] Polling attempt ${ + attemptsToRetryJoinGroup + 1 + } of ${GROUP_JOIN_REQUEST_POLL_MAX_ATTEMPTS}` + ); + const joinRequestData = await getGroupJoinRequest(joinRequestId); + logger.debug( + `[liveAttemptToJoinGroup] Join request status: ${joinRequestData.status}` + ); + + if (joinRequestData.status !== "PENDING") { + switch (joinRequestData.status) { + case "ACCEPTED": + logger.info( + `[liveAttemptToJoinGroup] Join request accepted for group: ${joinRequestData.groupId}` + ); + return { + type: "group-join-request.accepted", + groupId: joinRequestData.groupId as string, + }; + case "REJECTED": + logger.info(`[liveAttemptToJoinGroup] Join request rejected`); + return { type: "group-join-request.rejected" }; + case "ERROR": + logger.error(`[liveAttemptToJoinGroup] Error in join request`); + return { type: "group-join-request.error" }; + } + } + + attemptsToRetryJoinGroup += 1; + logger.debug( + `[liveAttemptToJoinGroup] Waiting ${GROUP_JOIN_REQUEST_POLL_INTERVAL_MS}ms before next poll` + ); + await sleep(GROUP_JOIN_REQUEST_POLL_INTERVAL_MS); + } + + logger.warn( + `[liveAttemptToJoinGroup] Join request timed out after ${GROUP_JOIN_REQUEST_POLL_MAX_ATTEMPTS} attempts` + ); + return { type: "group-join-request.timed-out" }; + }; + + const liveAllowGroup = async ({ + account, + group, + options, + }: AllowGroupProps) => { + // Dynamically import dependencies to avoid the need for mocking in tests + // and to make this client more flexible. This allows the tests to run + // without mocking these dependencies, which would be necessary if they + // were imported at the top level of this module. + const { setGroupStatus } = await import("@data/store/accountsStore"); + const { createAllowGroupMutationObserver } = await import( + "@queries/useAllowGroupMutation" + ); + + const { topic, id: groupId } = group; + logger.debug(`[JoinGroupClient] Allowing group ${topic}`); + const allowGroupMutationObserver = createAllowGroupMutationObserver({ + account, + topic, + groupId, + }); + await allowGroupMutationObserver.mutate(); + + // Dynamically import setGroupStatus + setGroupStatus({ [getGroupIdFromTopic(topic).toLowerCase()]: "allowed" }); + + const inboxIdsToAllow: InboxId[] = []; + const inboxIds: { [inboxId: string]: "allowed" } = {}; + if (options.includeAddedBy && group?.addedByInboxId) { + const addedBy = group.addedByInboxId; + inboxIds[addedBy as string] = "allowed"; + inboxIdsToAllow.push(addedBy); + } + }; + + const liveRefreshGroup = async (account: string, topic: string) => { + // Dynamically import dependencies to avoid the need for mocking in tests + // and to make this client more flexible. This allows the tests to run + // without mocking these dependencies, which would be necessary if they + // were imported at the top level of this module. + const { refreshGroup } = await import("@utils/xmtpRN/conversations"); + await refreshGroup(account, topic); + }; + + return new JoinGroupClient( + liveGetGroupInvite, + liveAttemptToJoinGroup, + liveFetchGroupsByAccount, + liveAllowGroup, + liveRefreshGroup + ); + } + + static userAMemberOfGroupWithId( + alreadyAMemberGroupId: string + ): JoinGroupClient { + const GroupIdUserAlreadyWasAMemberOf = alreadyAMemberGroupId; + + const fixtureGetGroupInvite = async (groupInviteId: string) => { + const fixtureGroupInvite: GroupInvite = { + id: "groupInviteId123", + inviteLink: "https://www.google.com", + createdByAddress: "0x123", + groupName: `Group Name from ${groupInviteId}`, + imageUrl: "https://www.google.com", + description: "Group Description", + groupId: GroupIdUserAlreadyWasAMemberOf, + } as const; + + return fixtureGroupInvite; + }; + + const fixtureAttemptToJoinGroup = async ( + account: string, + groupInviteId: string + ) => { + return { + type: "group-join-request.accepted", + groupId: GroupIdUserAlreadyWasAMemberOf, + } as const; + }; + + const fixtureFetchGroupsByAccount = async ( + account: string + ): Promise => { + const fixtureGroup: GroupData = { + id: GroupIdUserAlreadyWasAMemberOf, + createdAt: new Date().getTime(), + members: [], + topic: "topic123", + // has user been blocked? + isGroupActive: true, + state: "allowed", + creatorInboxId: "0xabc" as InboxId, + name: "Group Name", + addedByInboxId: "0x123" as InboxId, + imageUrlSquare: "https://www.google.com", + description: "Group Description", + } as const; + + const fixtureGroupsDataEntity: GroupsDataEntity = { + ids: [GroupIdUserAlreadyWasAMemberOf], + byId: { + [GroupIdUserAlreadyWasAMemberOf]: fixtureGroup, + }, + } as const; + + return fixtureGroupsDataEntity; + }; + + const fixtureAllowGroup = async ({ + account, + options, + group, + }: AllowGroupProps) => {}; + + const fixtureRefreshGroup = async (account: string, topic: string) => {}; + + return new JoinGroupClient( + fixtureGetGroupInvite, + fixtureAttemptToJoinGroup, + fixtureFetchGroupsByAccount, + fixtureAllowGroup, + fixtureRefreshGroup + ); + } + + static userNotAMemberOfGroupWithId( + notJoinedGroupId: string + ): JoinGroupClient { + const GroupIdUserIsNewTo = notJoinedGroupId; + const GroupIdUserIsAlreadyAMemberOf = "groupId123"; + + const fixtureGetGroupInvite = async (groupInviteId: string) => { + const fixtureGroupInvite: GroupInvite = { + id: "groupInviteId123", + inviteLink: "https://www.google.com", + createdByAddress: "0x123", + groupName: `Group Name from ${groupInviteId}`, + imageUrl: "https://www.google.com", + description: "Group Description", + groupId: GroupIdUserIsNewTo, + } as const; + + return fixtureGroupInvite; + }; + + const fixtureAttemptToJoinGroup = async ( + account: string, + groupInviteId: string + ) => { + return { + type: "group-join-request.accepted", + groupId: GroupIdUserIsNewTo, + } as const; + }; + + const fixtureFetchGroupsByAccount = async ( + account: string + ): Promise => { + const fixtureGroup: GroupData = { + id: GroupIdUserIsAlreadyAMemberOf, + createdAt: new Date().getTime(), + members: [], + topic: "topic123", + isGroupActive: true, + state: "allowed", + creatorInboxId: "0xabc" as InboxId, + name: "Group Name", + addedByInboxId: "0x123" as InboxId, + imageUrlSquare: "https://www.google.com", + description: "Group Description", + } as const; + + const fixtureGroupsDataEntity: GroupsDataEntity = { + ids: [fixtureGroup.id], + byId: { + [fixtureGroup.id]: fixtureGroup, + }, + } as const; + + // todo remove these from the fixture if they were to even get in + + return fixtureGroupsDataEntity; + }; + + const fixtureAllowGroup = async ({ + account, + options, + group, + }: AllowGroupProps) => {}; + + const fixtureRefreshGroup = async (account: string, topic: string) => {}; + + return new JoinGroupClient( + fixtureGetGroupInvite, + fixtureAttemptToJoinGroup, + fixtureFetchGroupsByAccount, + fixtureAllowGroup, + fixtureRefreshGroup + ); + } + + static userBlockedFromGroupWithId(blockedGroupId: string): JoinGroupClient { + const GroupIdUserWasBlockedFrom = blockedGroupId; + const UserWasBlockedFromGroupActiveValue = false; + + const fixtureGetGroupInvite = async (groupInviteId: string) => { + const fixtureGroupInvite: GroupInvite = { + id: "groupInviteId123", + inviteLink: "https://www.google.com", + createdByAddress: "0x123", + groupName: `Group Name from ${groupInviteId}`, + imageUrl: "https://www.google.com", + description: "Group Description", + groupId: GroupIdUserWasBlockedFrom, + } as const; + + return fixtureGroupInvite; + }; + + const fixtureAttemptToJoinGroup = async ( + account: string, + groupInviteId: string + ) => { + return { + type: "group-join-request.accepted", + groupId: GroupIdUserWasBlockedFrom, + } as const; + }; + + const fixtureFetchGroupsByAccount = async ( + account: string + ): Promise => { + const fixtureGroup: GroupData = { + id: GroupIdUserWasBlockedFrom, + createdAt: new Date().getTime(), + members: [], + topic: "topic123", + isGroupActive: UserWasBlockedFromGroupActiveValue, + state: "allowed", + creatorInboxId: "0xabc" as InboxId, + name: "Group Name", + addedByInboxId: "0x123" as InboxId, + imageUrlSquare: "https://www.google.com", + description: "Group Description", + } as const; + + const fixtureGroupsDataEntity: GroupsDataEntity = { + ids: [GroupIdUserWasBlockedFrom], + byId: { + [GroupIdUserWasBlockedFrom]: fixtureGroup, + }, + } as const; + + return fixtureGroupsDataEntity; + }; + + const fixtureAllowGroup = async () => {}; + + const fixtureRefreshGroup = async (account: string, topic: string) => {}; + + return new JoinGroupClient( + fixtureGetGroupInvite, + fixtureAttemptToJoinGroup, + fixtureFetchGroupsByAccount, + fixtureAllowGroup, + fixtureRefreshGroup + ); + } + + static userJoinGroupTimeout(attemptedJoinGroupId: string): JoinGroupClient { + const GroupIdUserWasNotAMemberOf = attemptedJoinGroupId; + + const fixtureGetGroupInvite = async (groupInviteId: string) => { + const fixtureGroupInvite: GroupInvite = { + id: "groupInviteId123", + inviteLink: "https://www.google.com", + createdByAddress: "0x123", + groupName: `Group Name from ${groupInviteId}`, + imageUrl: "https://www.google.com", + description: "Group Description", + groupId: GroupIdUserWasNotAMemberOf, + } as const; + + return fixtureGroupInvite; + }; + + const fixtureAttemptToJoinGroup = async ( + account: string, + groupInviteId: string + ): Promise => { + await sleep(5000); + return { + type: "group-join-request.timed-out", + } as const; + }; + + const fixtureFetchGroupsByAccount = async ( + account: string + ): Promise => { + const fixtureGroupsDataEntity: GroupsDataEntity = { + ids: [], + byId: {}, + } as const; + + return fixtureGroupsDataEntity; + }; + + const fixtureAllowGroup = async () => {}; + + const fixtureRefreshGroup = async (account: string, topic: string) => {}; + + return new JoinGroupClient( + fixtureGetGroupInvite, + fixtureAttemptToJoinGroup, + fixtureFetchGroupsByAccount, + fixtureAllowGroup, + fixtureRefreshGroup + ); + } + + static unimplemented(): JoinGroupClient { + const unimplementedError = (method: string) => () => { + const error = ` +[JoinGroupClient] ERROR: unimplemented ${method} - Your code has invoked JoinGroupClient +without specifying an implementation. This unimplemented dependency is here to +ensure you don't invoke code you don't intend to, ensuring your tests are truly +testing what they are expected to +`; + console.warn(error); + throw new Error(error); + }; + + return new JoinGroupClient( + unimplementedError("fetchGroupInvite"), + unimplementedError("attemptToJoinGroup"), + unimplementedError("fetchGroupsByAccount"), + unimplementedError("allowGroup"), + unimplementedError("refreshGroup") + ); + } +} diff --git a/hooks/useAppStateHandlers.ts b/hooks/useAppStateHandlers.ts index 774b7a9b9..5a9a80ed9 100644 --- a/hooks/useAppStateHandlers.ts +++ b/hooks/useAppStateHandlers.ts @@ -1,13 +1,13 @@ import { useEffect, useRef } from "react"; import { AppState, AppStateStatus } from "react-native"; -export interface AppStateHookSettings { +export type AppStateHookSettings = { onChange?: (status: AppStateStatus) => void; onForeground?: () => void; onBackground?: () => void; onInactive?: () => void; deps?: React.DependencyList; -} +}; type Handler = (state: AppStateStatus) => void; diff --git a/hooks/usePhotoSelect.ts b/hooks/usePhotoSelect.ts index 799dc95b9..f26fcb2d2 100644 --- a/hooks/usePhotoSelect.ts +++ b/hooks/usePhotoSelect.ts @@ -10,11 +10,11 @@ import { takePictureFromCamera, } from "../utils/media"; -interface PhotoSelect { +type PhotoSelect = { initialPhoto?: string; onPhotoAdd?: (newUrl: string) => void; isAvatar?: boolean; -} +}; export const usePhotoSelect = (payload?: PhotoSelect) => { const { initialPhoto, onPhotoAdd, isAvatar } = payload ?? {}; diff --git a/hooks/usePreferredNames.ts b/hooks/usePreferredNames.ts index 9eec81df5..325a0a20a 100644 --- a/hooks/usePreferredNames.ts +++ b/hooks/usePreferredNames.ts @@ -10,6 +10,7 @@ import { useProfilesSocials } from "./useProfilesSocials"; */ export const usePreferredNames = (peerAddresses: string[]) => { const data = useProfilesSocials(peerAddresses); + const names = useMemo(() => { // Not sure how performant this will be, or if we can safely rely on the index // If we can't, we should probably use a Map instead diff --git a/i18n/i18n.ts b/i18n/i18n.ts index 28aa2a025..d7e649fd5 100644 --- a/i18n/i18n.ts +++ b/i18n/i18n.ts @@ -1,5 +1,5 @@ import * as Localization from "expo-localization"; -// eslint-disable-next-line no-restricted-imports + import i18n from "i18n-js"; import { I18nManager } from "react-native"; @@ -65,5 +65,5 @@ type RecursiveKeyOfHandleValue< > = TValue extends any[] ? Text : TValue extends object - ? Text | `${Text}${RecursiveKeyOfInner}` - : Text; + ? Text | `${Text}${RecursiveKeyOfInner}` + : Text; diff --git a/i18n/translate.ts b/i18n/translate.ts index 91d7c1403..86c1dd7f1 100644 --- a/i18n/translate.ts +++ b/i18n/translate.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line no-restricted-imports import i18n from "i18n-js"; import { TxKeyPath } from "./i18n"; diff --git a/metro.config.js b/metro.config.js index 62b2bed66..09fdfbc02 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,7 +1,7 @@ const { mergeConfig } = require("@react-native/metro-config"); const { getSentryExpoConfig } = require("@sentry/react-native/metro"); -// eslint-disable-next-line no-undef + const defaultConfig = getSentryExpoConfig(__dirname, { // [Web-only]: Enables CSS support in Metro. isCSSEnabled: true, diff --git a/queries/entify.ts b/queries/entify.ts index ed1c9a97d..ea5a3dde7 100644 --- a/queries/entify.ts +++ b/queries/entify.ts @@ -1,8 +1,36 @@ +/** + * Represents an object with items indexed by their ids. + * + * @template T The type of items in the object + * @template KeyType The type of ids (default: string) + */ export type EntityObject = { byId: Record; ids: KeyType[]; }; +/** + * Transforms an array of items into an EntityObject. + * + * @param items - Array of items to be transformed + * @param idFunc - Function to extract the id from an item + * @returns EntityObject with items indexed by their ids + * + * @example + * const users = [ + * { id: 'user1', name: 'Alice' }, + * { id: 'user2', name: 'Bob' } + * ]; + * const userEntity = entify(users, user => user.id); + * // Result: + * // { + * // byId: { + * // user1: { id: 'user1', name: 'Alice' }, + * // user2: { id: 'user2', name: 'Bob' } + * // }, + * // ids: ['user1', 'user2'] + * // } + */ export function entify( items: T[], idFunc: (item: T) => KeyType @@ -21,12 +49,46 @@ export function entify( ); } +/** + * Represents an EntityObject with an additional byAddress index. + */ export type EntityObjectWithAddress = { byId: Record; byAddress: Record; ids: KeyType[]; }; +/** + * Transforms an array of items into an EntityObjectWithAddress. + * + * @param items - Array of items to be transformed + * @param idFunc - Function to extract the id from an item + * @param addressFunc - Function to extract the address from an item + * @returns EntityObjectWithAddress with items indexed by ids and addresses + * + * @example + * const users = [ + * { id: 'user1', name: 'Alice', address: '0xD3ADB33F' }, + * { id: 'user2', name: 'Bob', address: '0xC0FFEE42' } + * ]; + * const userEntity = entifyWithAddress( + * users, + * user => user.id, + * user => user.address + * ); + * // Result: + * // { + * // byId: { + * // user1: { id: 'user1', name: 'Alice', address: '0xD3ADB33F' }, + * // user2: { id: 'user2', name: 'Bob', address: '0xC0FFEE42' } + * // }, + * // byAddress: { + * // '0xD3ADB33F': 'user1', + * // '0xC0FFEE42': 'user2' + * // }, + * // ids: ['user1', 'user2'] + * // } + */ export function entifyWithAddress( items: T[], idFunc: (item: T) => KeyType, @@ -49,6 +111,26 @@ export function entifyWithAddress( ); } +/** + * Transforms an array of pages (arrays) of items into a single EntityObject. + * + * @param pages - Array of arrays of items to be transformed + * @param idFunc - Function to extract the id from an item + * @returns EntityObject with all items from all pages indexed by their ids + * + * @example + * const page1 = [{ id: 'user1', name: 'Alice', address: '0xBEEF1234' }]; + * const page2 = [{ id: 'user2', name: 'Bob', address: '0xFACE5678' }]; + * const userEntity = enitifyPages([page1, page2], user => user.id); + * // Result: + * // { + * // byId: { + * // user1: { id: 'user1', name: 'Alice', address: '0xBEEF1234' }, + * // user2: { id: 'user2', name: 'Bob', address: '0xFACE5678' } + * // }, + * // ids: ['user1', 'user2'] + * // } + */ export function enitifyPages( pages: T[][], idFunc: (item: T) => string diff --git a/queries/groupConsentMutationUtils.ts b/queries/groupConsentMutationUtils.ts new file mode 100644 index 000000000..3c9d238f3 --- /dev/null +++ b/queries/groupConsentMutationUtils.ts @@ -0,0 +1,109 @@ +import { MutationObserver, QueryClient } from "@tanstack/react-query"; +import logger from "@utils/logger"; +import { sentryTrackError } from "@utils/sentry"; +import { consentToGroupsOnProtocol } from "@utils/xmtpRN/conversations"; + +import { + cancelGroupConsentQuery, + Consent, + getGroupConsentQueryData, + setGroupConsentQueryData, +} from "./useGroupConsentQuery"; + +export type GroupConsentAction = "allow" | "deny"; + +export type GroupConsentMutationProps = { + account: string; + topic: string; + groupId: string; + action: GroupConsentAction; +}; + +export const createGroupConsentMutationObserver = ( + queryClient: QueryClient, + mutationKey: unknown[], + { account, topic, groupId, action }: GroupConsentMutationProps +) => { + const consentStatus = action === "allow" ? "allowed" : "denied"; + + return new MutationObserver(queryClient, { + mutationKey, + mutationFn: async () => { + await consentToGroupsOnProtocol(account, [groupId], action); + return consentStatus; + }, + onMutate: async () => { + await cancelGroupConsentQuery(account, topic); + const previousConsent = getGroupConsentQueryData(account, topic); + setGroupConsentQueryData(account, topic, consentStatus); + return { previousConsent }; + }, + onError: (error, _variables, context) => { + logger.warn( + `onError use${ + action.charAt(0).toUpperCase() + action.slice(1) + }GroupMutation` + ); + sentryTrackError(error); + if (context?.previousConsent === undefined) { + return; + } + setGroupConsentQueryData(account, topic, context.previousConsent); + }, + onSuccess: () => { + logger.debug( + `onSuccess use${ + action.charAt(0).toUpperCase() + action.slice(1) + }GroupMutation` + ); + }, + }); +}; + +export const getGroupConsentMutationOptions = ({ + account, + topic, + groupId, + action, +}: GroupConsentMutationProps) => { + const consentStatus = action === "allow" ? "allowed" : "denied"; + + return { + mutationFn: async () => { + if (!groupId || !account) { + return; + } + await consentToGroupsOnProtocol(account, [groupId], action); + return consentStatus; + }, + onMutate: async () => { + await cancelGroupConsentQuery(account, topic); + const previousConsent = getGroupConsentQueryData(account, topic); + setGroupConsentQueryData(account, topic, consentStatus); + return { previousConsent }; + }, + onError: ( + error: unknown, + _variables: unknown, + context: { previousConsent?: Consent } + ) => { + logger.warn( + `onError use${ + action.charAt(0).toUpperCase() + action.slice(1) + }GroupMutation` + ); + sentryTrackError(error); + if (context?.previousConsent === undefined) { + return; + } + setGroupConsentQueryData(account, topic, context.previousConsent); + }, + onSuccess: () => { + logger.debug( + `onSuccess use${ + action.charAt(0).toUpperCase() + action.slice(1) + }GroupMutation` + ); + }, + }; +}; diff --git a/queries/useAllowGroupMutation.ts b/queries/useAllowGroupMutation.ts index 88d8b6ae0..d575de254 100644 --- a/queries/useAllowGroupMutation.ts +++ b/queries/useAllowGroupMutation.ts @@ -1,7 +1,12 @@ -import { useMutation } from "@tanstack/react-query"; +import { useGroupId } from "@hooks/useGroupId"; +import { queryClient } from "@queries/queryClient"; +import { useMutation, MutationObserver } from "@tanstack/react-query"; import logger from "@utils/logger"; import { sentryTrackError } from "@utils/sentry"; -import { consentToGroupsOnProtocolByAccount } from "@utils/xmtpRN/contacts"; +import { + consentToGroupsOnProtocol, + consentToGroupsOnProtocolByAccount, +} from "@utils/xmtpRN/contacts"; import type { ConversationTopic } from "@xmtp/react-native-sdk"; import { allowGroupMutationKey } from "./MutationKeys"; @@ -12,10 +17,52 @@ import { } from "./useGroupConsentQuery"; import { getV3IdFromTopic } from "@utils/groupUtils/groupId"; -export const useAllowGroupMutation = ( - account: string, - topic: ConversationTopic -) => { +export type AllowGroupMutationProps = { + account: string; + topic: string; + groupId: string; +}; + +export const createAllowGroupMutationObserver = ({ + account, + topic, + groupId, +}: AllowGroupMutationProps) => { + const allowGroupMutationObserver = new MutationObserver(queryClient, { + mutationKey: allowGroupMutationKey(account, topic), + mutationFn: async () => { + // export const consentToGroupsOnProtocol = async ({ + // client, + // groupIds, + // consent, + // }: ConsentToGroupsOnProtocolParams) => { + await consentToGroupsOnProtocol(account, [groupId], "allow"); + return "allowed"; + }, + onMutate: async () => { + await cancelGroupConsentQuery(account, topic); + const previousConsent = getGroupConsentQueryData(account, topic); + setGroupConsentQueryData(account, topic, "allowed"); + return { previousConsent }; + }, + onError: (error, _variables, context) => { + logger.warn("onError useAllowGroupMutation"); + sentryTrackError(error); + if (context?.previousConsent === undefined) { + return; + } + setGroupConsentQueryData(account, topic, context.previousConsent); + }, + onSuccess: () => { + logger.debug("onSuccess useAllowGroupMutation"); + }, + }); + return allowGroupMutationObserver; +}; + +export const useAllowGroupMutation = (account: string, topic: string) => { + const { groupId } = useGroupId(topic); + // >>>>>>> bd191fb2 (feat: Add Dependency Control Pattern) return useMutation({ mutationKey: allowGroupMutationKey(account, topic), mutationFn: async () => { diff --git a/queries/useBlockGroupMutation.ts b/queries/useBlockGroupMutation.ts index ee3066cb0..470a9dbbc 100644 --- a/queries/useBlockGroupMutation.ts +++ b/queries/useBlockGroupMutation.ts @@ -1,4 +1,6 @@ -import { useMutation } from "@tanstack/react-query"; +import { useGroupId } from "@hooks/useGroupId"; +import { queryClient } from "@queries/queryClient"; +import { useMutation, MutationObserver } from "@tanstack/react-query"; import logger from "@utils/logger"; import { sentryTrackError } from "@utils/sentry"; import { consentToGroupsOnProtocolByAccount } from "@utils/xmtpRN/contacts"; @@ -12,10 +14,51 @@ import { import type { ConversationTopic } from "@xmtp/react-native-sdk"; import { getV3IdFromTopic } from "@utils/groupUtils/groupId"; -export const useBlockGroupMutation = ( - account: string, - topic: ConversationTopic | undefined -) => { +export type BlockGroupMutationProps = { + account: string; + topic: string; + groupId: string; +}; + +// export const useBlockGroupMutation = ( +// account: string, +// topic: ConversationTopic | undefined +// ) => { + +const createBlockGroupMutationObserver = ({ + account, + topic, + groupId, +}: BlockGroupMutationProps) => { + const blockGroupMutationObserver = new MutationObserver(queryClient, { + mutationKey: blockGroupMutationKey(account, topic), + mutationFn: async () => { + await consentToGroupsOnProtocol(account, [groupId], "deny"); + return "denied"; + }, + onMutate: async () => { + await cancelGroupConsentQuery(account, topic); + const previousConsent = getGroupConsentQueryData(account, topic); + setGroupConsentQueryData(account, topic, "denied"); + return { previousConsent }; + }, + onError: (error, _variables, context) => { + logger.warn("onError useBlockGroupMutation"); + sentryTrackError(error); + if (context?.previousConsent === undefined) { + return; + } + setGroupConsentQueryData(account, topic, context.previousConsent); + }, + onSuccess: () => { + logger.debug("onSuccess useBlockGroupMutation"); + }, + }); + return blockGroupMutationObserver; +}; + +export const useBlockGroupMutation = (account: string, topic: string) => { + const { groupId } = useGroupId(topic); return useMutation({ mutationKey: blockGroupMutationKey(account, topic!), mutationFn: async () => { @@ -36,7 +79,7 @@ export const useBlockGroupMutation = ( return { previousConsent }; }, onError: (error, _variables, context) => { - logger.warn("onError useDenyGroupMutation"); + logger.warn("onError useBlockGroupMutation"); sentryTrackError(error); if (context?.previousConsent === undefined) { return; @@ -44,7 +87,7 @@ export const useBlockGroupMutation = ( setGroupConsentQueryData(account, topic!, context.previousConsent); }, onSuccess: () => { - logger.debug("onSuccess useDenyGroupMutation"); + logger.debug("onSuccess useBlockGroupMutation"); }, }); }; diff --git a/queries/useGroupInviteQuery.ts b/queries/useGroupInviteQuery.ts index 2932cf17c..ce677b421 100644 --- a/queries/useGroupInviteQuery.ts +++ b/queries/useGroupInviteQuery.ts @@ -1,6 +1,7 @@ import { useCurrentAccount } from "@data/store/accountsStore"; import { useQuery } from "@tanstack/react-query"; -import { getGroupInvite, GroupInvite } from "@utils/api"; +import { getGroupInvite } from "@utils/api"; +import { GroupInvite } from "@utils/api.types"; import { groupInviteQueryKey } from "./QueryKeys"; diff --git a/queries/useGroupsQuery.ts b/queries/useGroupsQuery.ts new file mode 100644 index 000000000..25dc082bb --- /dev/null +++ b/queries/useGroupsQuery.ts @@ -0,0 +1,96 @@ +import { useQuery } from "@tanstack/react-query"; +import logger from "@utils/logger"; +import { + AnyGroup, + ConverseXmtpClientType, + GroupsEntity, + GroupWithCodecsType, +} from "@utils/xmtpRN/client.types"; +import { getXmtpClient } from "@utils/xmtpRN/sync"; + +import { groupsQueryKey } from "./QueryKeys"; +import { entify, EntityObject } from "./entify"; +import { queryClient } from "./queryClient"; + +type GroupMembersSelectData = EntityObject; + +export const groupsQueryFn = async (account: string): Promise => { + const client = (await getXmtpClient(account)) as ConverseXmtpClientType; + if (!client) { + return { + byId: {}, + ids: [], + }; + } + const beforeSync = new Date().getTime(); + await client.conversations.syncGroups(); + const afterSync = new Date().getTime(); + logger.debug( + `[Groups] Fetching group list from network took ${ + (afterSync - beforeSync) / 1000 + } sec` + ); + const groups: AnyGroup[] = await client.conversations.listGroups(); + const afterList = new Date().getTime(); + logger.debug( + `[Groups] Listing ${groups.length} groups took ${ + (afterList - afterSync) / 1000 + } sec` + ); + const groupEntity: GroupsEntity = entify(groups, (group) => group.topic); + logger.debug(`[Groups] Fetched ${groupEntity.ids.length} groups`); + return groupEntity; +}; + +export const useGroupsQuery = (account: string) => { + return useQuery({ + queryKey: groupsQueryKey(account), + queryFn: () => groupsQueryFn(account), + enabled: !!account, + }); +}; + +export const fetchGroupsQuery = ( + account: string, + staleTime?: number +): Promise => { + return queryClient.fetchQuery({ + queryKey: groupsQueryKey(account), + queryFn: () => groupsQueryFn(account), + staleTime, + }); +}; + +export const invalidateGroupsQuery = (account: string) => { + return queryClient.invalidateQueries({ queryKey: groupsQueryKey(account) }); +}; + +const getGroupsQueryData = ( + account: string +): GroupMembersSelectData | undefined => + queryClient.getQueryData(groupsQueryKey(account)); + +const setGroupsQueryData = ( + account: string, + groups: GroupMembersSelectData +) => { + queryClient.setQueryData(groupsQueryKey(account), groups); +}; + +export const addGroupToGroupsQuery = ( + account: string, + group: GroupWithCodecsType +) => { + const previousGroupsData = getGroupsQueryData(account); + if (!previousGroupsData) { + return; + } + + setGroupsQueryData(account, { + byId: { + ...previousGroupsData.byId, + [group.topic]: group, + }, + ids: [...previousGroupsData.ids, group.topic], + }); +}; diff --git a/scripts/build/build.js b/scripts/build/build.js index 3bd21f7e8..d76e68b02 100644 --- a/scripts/build/build.js +++ b/scripts/build/build.js @@ -6,7 +6,7 @@ const prompts = require("prompts"); const { handleEnv } = require("./eas"); const appJson = require("../../app.json"); -// eslint-disable-next-line no-undef + const PROJECT_ROOT = path.join(__dirname, "..", ".."); const build = async () => { diff --git a/scripts/build/update.js b/scripts/build/update.js index a7e97b71d..a54cb892a 100644 --- a/scripts/build/update.js +++ b/scripts/build/update.js @@ -3,7 +3,7 @@ const isClean = require("git-is-clean"); const path = require("path"); const prompts = require("prompts"); -// eslint-disable-next-line no-undef + const PROJECT_ROOT = path.join(__dirname, "..", ".."); const update = async () => { diff --git a/scripts/wasm.js b/scripts/wasm.js index 1830a337e..85e654e6d 100644 --- a/scripts/wasm.js +++ b/scripts/wasm.js @@ -3,7 +3,7 @@ const fs = require("fs"); const path = require("path"); -// eslint-disable-next-line no-undef + const PROJECT_ROOT = path.join(__dirname, ".."); const WASM_FOLDER = path.join(PROJECT_ROOT, "public", "wasm"); diff --git a/tsconfig.json b/tsconfig.json index 1ac5e4305..3ea47e8e5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "expo/tsconfig.base", "compilerOptions": { + "module": "ESNext", "strict": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, diff --git a/utils/api.test.ts b/utils/api.test.ts new file mode 100644 index 000000000..dba7f3154 --- /dev/null +++ b/utils/api.test.ts @@ -0,0 +1,117 @@ +import { Client } from "@xmtp/xmtp-js"; + +import { + createGroupInvite, + createGroupJoinRequest, + getGroupInvite, + getGroupJoinRequest, + getPendingGroupJoinRequests, + updateGroupJoinRequestStatus, +} from "./api"; +import { randomClient } from "./test/xmtp"; + +jest.mock("./keychain", () => { + const data: Record = {}; + + return { + setSecureItemAsync(key: string, value: string) { + data[key] = value; + Promise.resolve(); + }, + getSecureItemAsync(key: string) { + return Promise.resolve(data[key] ?? null); + }, + deleteSecureItemAsync(key: string) { + delete data[key]; + return Promise.resolve(); + }, + }; +}); + +// todo(lustig): incorporate these tests into JoinGroupClient#live +describe("GroupInviteLink", () => { + let client: Client; + + const groupName = "test"; + const description = "description"; + const imageUrl = "image"; + const groupId = "group"; + + beforeEach(async () => { + client = await randomClient("dev"); + }); + + it("should create a group invite link and read it back", async () => { + const groupInviteLink = await createGroupInvite(client.address, { + groupName, + description, + imageUrl, + groupId, + }); + expect(groupInviteLink.id).toBeDefined(); + + const fromBackend = await getGroupInvite(groupInviteLink.id); + expect(fromBackend.groupName).toEqual(groupName); + expect(fromBackend.imageUrl).toEqual(imageUrl); + expect(fromBackend.id).toEqual(groupInviteLink.id); + }); + + it("should allow joining a group", async () => { + const groupInviteLink = await createGroupInvite(client.address, { + groupName, + description, + imageUrl, + groupId, + }); + + const secondClient = await randomClient("dev"); + const { id: joinRequestId } = await createGroupJoinRequest( + secondClient.address, + groupInviteLink.id + ); + + expect(joinRequestId).toBeDefined(); + expect(joinRequestId).not.toEqual(groupInviteLink.id); + + // Fetch it back from the network + const fetched = await getGroupJoinRequest(joinRequestId); + expect(fetched.id).toEqual(joinRequestId); + expect(fetched.invitedByAddress).toEqual(client.address.toLowerCase()); + expect(fetched.status).toEqual("PENDING"); + expect(fetched.groupName).toEqual(groupName); + }); + + it("should allow updating a join request", async () => { + const groupInviteLink = await createGroupInvite(client.address, { + groupName, + description, + imageUrl, + groupId, + }); + + const secondClient = await randomClient("dev"); + const { id: joinRequestId } = await createGroupJoinRequest( + secondClient.address, + groupInviteLink.id + ); + + const pending = await getPendingGroupJoinRequests(client.address); + expect(pending.joinRequests).toHaveLength(1); + const joinRequest = pending.joinRequests[0]; + expect(joinRequest.id).toEqual(joinRequestId); + expect(joinRequest.groupInviteLinkId).toEqual(groupInviteLink.id); + expect(joinRequest.requesterAddress).toEqual( + secondClient.address.toLowerCase() + ); + expect(joinRequest.status).toEqual("PENDING"); + + // Set to rejected + await updateGroupJoinRequestStatus( + client.address, + joinRequestId, + "REJECTED" + ); + const newPending = await getPendingGroupJoinRequests(client.address); + expect(newPending.joinRequests).toHaveLength(0); + }); +}); diff --git a/utils/api.ts b/utils/api.ts index f14801654..498509049 100644 --- a/utils/api.ts +++ b/utils/api.ts @@ -1,5 +1,6 @@ import { TransactionData } from "@components/TransactionPreview/TransactionPreview"; import type { SimulateAssetChangesResponse } from "alchemy-sdk"; +import { GroupInvite } from "@utils/api.types"; import axios from "axios"; import * as Contacts from "expo-contacts"; @@ -21,7 +22,7 @@ import { getXmtpApiHeaders } from "../utils/xmtpRN/api"; import type { InboxId } from "@xmtp/react-native-sdk"; import { evmHelpers } from "./evm/helpers"; -const api = axios.create({ +export const api = axios.create({ baseURL: config.apiURI, }); @@ -443,16 +444,6 @@ export const joinGroupFromLink = async ( return data as JoinGroupLinkResult; }; -export type GroupInvite = { - id: string; - inviteLink: string; - createdByAddress: string; - groupName: string; - imageUrl?: string; - description?: string; - groupId?: string; -}; - export type CreateGroupInviteResult = Pick; // Create a group invite. The invite will be associated with the account used. @@ -517,6 +508,7 @@ export const createGroupJoinRequest = async ( headers: await getXmtpApiHeaders(account), } ); + logger.debug("[API] Group join request created", data); return data; }; diff --git a/utils/api.types.ts b/utils/api.types.ts new file mode 100644 index 000000000..f45768b3a --- /dev/null +++ b/utils/api.types.ts @@ -0,0 +1,9 @@ +export type GroupInvite = { + id: string; + inviteLink: string; + createdByAddress: string; + groupName: string; + imageUrl?: string; + description?: string; + groupId?: string; +}; diff --git a/utils/date.test.ts b/utils/date.test.ts index a4b9a2b99..556065fb8 100644 --- a/utils/date.test.ts +++ b/utils/date.test.ts @@ -1,5 +1,4 @@ -import { format } from "date-fns"; -import { enUS, fr } from "date-fns/locale"; +import { format, enUS, fr } from "date-fns"; import { getLocales } from "react-native-localize"; import { getMinimalDate, getRelativeDate, getRelativeDateTime } from "./date"; diff --git a/utils/emojis/interfaces.ts b/utils/emojis/interfaces.ts index f619cec96..8b7f46b77 100644 --- a/utils/emojis/interfaces.ts +++ b/utils/emojis/interfaces.ts @@ -1,12 +1,12 @@ -export interface Emoji { +export type Emoji = { emoji: string; name: string; toneEnabled: boolean; keywords: string[]; -} +}; -export interface CategorizedEmojisRecord { +export type CategorizedEmojisRecord = { id: string; category: string; emojis: Emoji[]; -} +}; diff --git a/utils/evm/erc20.ts b/utils/evm/erc20.ts index f6413c544..786cd1acf 100644 --- a/utils/evm/erc20.ts +++ b/utils/evm/erc20.ts @@ -88,14 +88,14 @@ export async function getErc20TokenSymbol( return symbol; } -export interface TransferAuthorizationMessage { +export type TransferAuthorizationMessage = { from: string; to: string; value: any; validAfter: number; validBefore: number; nonce: string; -} +}; /** * Computes the domain separator for a given ERC20 contract diff --git a/utils/evm/xmtp.ts b/utils/evm/xmtp.ts index e9881cb96..849afbfc0 100644 --- a/utils/evm/xmtp.ts +++ b/utils/evm/xmtp.ts @@ -16,6 +16,7 @@ export const useXmtpSigner = () => { const account = useCurrentAccount() as string; const privySigner = usePrivySigner(); const { getExternalSigner, resetExternalSigner } = useExternalSigner(); + const getXmtpSigner = useCallback(async () => { const client = (await getXmtpClient(account)) as ConverseXmtpClientType; diff --git a/utils/groupUtils/adminUtils.test.ts b/utils/groupUtils/adminUtils.test.ts index 633380555..9fa3f725d 100644 --- a/utils/groupUtils/adminUtils.test.ts +++ b/utils/groupUtils/adminUtils.test.ts @@ -8,11 +8,11 @@ import { getAddressIsSuperAdmin, } from "./adminUtils"; // adjust the import path -interface EntityObjectWithAddress { +type EntityObjectWithAddress = { byId: Record; byAddress: Record; ids: K[]; -} +}; const mockMembers: EntityObjectWithAddress = { byId: { diff --git a/utils/xmtpRN/client.types.ts b/utils/xmtpRN/client.types.ts new file mode 100644 index 000000000..6a0138801 --- /dev/null +++ b/utils/xmtpRN/client.types.ts @@ -0,0 +1,50 @@ +import { EntityObject } from "@queries/entify"; +import { getXmtpClientFromBase64Key } from "@utils/xmtpRN/client"; +import { Group } from "@xmtp/react-native-sdk"; + +export type ConverseXmtpClientType = Awaited< + ReturnType +>; + +export type ConversationWithCodecsType = Awaited< + ReturnType +>; + +// while this gets us auto complete, it's very cumbersome to jump into the +// actual definition, hence any group below. cmd+click jumps right to +// the sdk definition +export type GroupWithCodecsType = Awaited< + ReturnType +>; + +// It's a little strange that there is no group type without behavior +// the behavior is driven by the generic codecs but are unused +// simply for data purposes, +export type AnyGroup = Group; + +export type DecodedMessageWithCodecsType = Awaited< + ReturnType +>[number]; + +export type XmtpClientByAccount = { + [account: string]: ConverseXmtpClientType; +}; + +export type GroupData = Pick< + AnyGroup, + | "id" + | "createdAt" + | "members" + | "topic" + | "creatorInboxId" + | "name" + | "isGroupActive" + | "addedByInboxId" + | "imageUrlSquare" + | "description" + | "state" +>; + +export type GroupsDataEntity = EntityObject; + +export type GroupsEntity = EntityObject;