From 08aaa6e1377b95985e9acd7d9d8d33dd761ed068 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Fri, 4 Oct 2024 13:17:37 -0400 Subject: [PATCH 1/5] feat: Pressable Group Updates Added handling when pressing a display name in the group updated messages --- components/Chat/ChatGroupUpdatedMessage.tsx | 76 ++++++++++++++++----- components/PressableProfileWithText.tsx | 48 +++++++++++++ i18n/i18n.ts | 2 +- i18n/translations/en.ts | 13 +++- 4 files changed, 118 insertions(+), 21 deletions(-) create mode 100644 components/PressableProfileWithText.tsx diff --git a/components/Chat/ChatGroupUpdatedMessage.tsx b/components/Chat/ChatGroupUpdatedMessage.tsx index 1dd1b49ba..c73b511a7 100644 --- a/components/Chat/ChatGroupUpdatedMessage.tsx +++ b/components/Chat/ChatGroupUpdatedMessage.tsx @@ -1,7 +1,9 @@ +import { PressableProfileWithText } from "@components/PressableProfileWithText"; +import { translate } from "@i18n"; import { textSecondaryColor } from "@styles/colors"; import { GroupUpdatedContent } from "@xmtp/react-native-sdk"; import { useMemo } from "react"; -import { StyleSheet, Text, useColorScheme } from "react-native"; +import { StyleSheet, useColorScheme } from "react-native"; import { MessageToDisplay } from "./Message/Message"; import { @@ -26,11 +28,16 @@ export default function ChatGroupUpdatedMessage({ // TODO: Feat: handle multiple members const initiatedByAddress = byInboxId[parsedContent.initiatedByInboxId]?.[0]; + const initiatedByProfile = getProfile(initiatedByAddress, profiles)?.socials; const initiatedByReadableName = getPreferredName( - getProfile(initiatedByAddress, profiles)?.socials, + initiatedByProfile, initiatedByAddress ); - const membersActions: string[] = []; + const membersActions: { + address: string; + content: string; + readableName: string; + }[] = []; parsedContent.membersAdded.forEach((m) => { // TODO: Feat: handle multiple members const firstAddress = byInboxId[m.inboxId]?.[0]; @@ -40,7 +47,13 @@ export default function ChatGroupUpdatedMessage({ getProfile(firstAddress, profiles)?.socials, firstAddress ); - membersActions.push(`${readableName} joined the conversation`); + membersActions.push({ + address: firstAddress, + content: translate(`group_member_joined`, { + name: readableName, + }), + readableName, + }); }); parsedContent.membersRemoved.forEach((m) => { // TODO: Feat: handle multiple members @@ -51,30 +64,54 @@ export default function ChatGroupUpdatedMessage({ getProfile(firstAddress, profiles)?.socials, firstAddress ); - membersActions.push(`${readableName} left the conversation`); + membersActions.push({ + address: firstAddress, + content: translate(`group_member_left`, { + name: readableName, + }), + readableName, + }); }); parsedContent.metadataFieldsChanged.forEach((f) => { if (f.fieldName === "group_name") { - membersActions.push( - `${initiatedByReadableName} changed the group name to "${f.newValue}".` - ); + membersActions.push({ + address: initiatedByAddress, + content: translate(`group_name_changed_to`, { + name: initiatedByReadableName, + newValue: f.newValue, + }), + readableName: initiatedByReadableName, + }); } else if (f.fieldName === "group_image_url_square") { - membersActions.push( - `${initiatedByReadableName} changed the group photo.` - ); + membersActions.push({ + address: initiatedByAddress, + content: translate(`group_photo_changed`, { + name: initiatedByReadableName, + }), + readableName: initiatedByReadableName, + }); } else if (f.fieldName === "description") { - membersActions.push( - `${initiatedByReadableName} changed the group description to "${f.newValue}".` - ); + membersActions.push({ + address: initiatedByAddress, + content: translate(`group_description_changed`, { + name: initiatedByReadableName, + newValue: f.newValue, + }), + readableName: initiatedByReadableName, + }); } }); return ( <> {membersActions.map((a) => ( - - {a} - + ))} ); @@ -83,6 +120,11 @@ export default function ChatGroupUpdatedMessage({ const useStyles = () => { const colorScheme = useColorScheme(); return StyleSheet.create({ + textContainer: { + flexDirection: "row", + alignItems: "center", + textAlign: "center", + }, groupChange: { color: textSecondaryColor(colorScheme), fontSize: 11, diff --git a/components/PressableProfileWithText.tsx b/components/PressableProfileWithText.tsx new file mode 100644 index 000000000..fd452adfc --- /dev/null +++ b/components/PressableProfileWithText.tsx @@ -0,0 +1,48 @@ +import { navigate } from "@utils/navigation"; +import { memo, useCallback, useMemo } from "react"; +import { StyleProp, TextStyle } from "react-native"; +import ParsedText from "react-native-parsed-text"; + +const PressableProfileWithTextInner = ({ + profileAddress, + profileDisplay, + text, + textStyle, +}: { + text: string; + profileDisplay: string; + profileAddress: string; + textStyle?: StyleProp; +}) => { + const onPress = useCallback(() => { + if (profileAddress) { + return navigate("Profile", { + address: profileAddress, + }); + } + return undefined; + }, [profileAddress]); + + const pattern = useMemo( + () => new RegExp(profileDisplay, "g"), + [profileDisplay] + ); + const parseOptions = useMemo( + () => [ + { + onPress, + pattern, + style: textStyle, + }, + ], + [onPress, pattern, textStyle] + ); + + return ( + + {text} + + ); +}; + +export const PressableProfileWithText = memo(PressableProfileWithTextInner); diff --git a/i18n/i18n.ts b/i18n/i18n.ts index b45aeae0e..28aa2a025 100644 --- a/i18n/i18n.ts +++ b/i18n/i18n.ts @@ -3,7 +3,7 @@ import * as Localization from "expo-localization"; import i18n from "i18n-js"; import { I18nManager } from "react-native"; -import en, { Translations } from "./translations/en"; +import { en, Translations } from "./translations/en"; export { i18n }; // import fr from "./translations/fr"; diff --git a/i18n/translations/en.ts b/i18n/translations/en.ts index 67993e3ce..c01ac0e40 100644 --- a/i18n/translations/en.ts +++ b/i18n/translations/en.ts @@ -1,4 +1,4 @@ -const en = { +export const en = { // Onboarding walletSelector: { title: "Your messages.\nYour privacy.", @@ -336,7 +336,14 @@ const en = { // New Conversation cannot_be_added_to_group_yet: "{{name}} needs to update Converse to be added to a group", -}; -export default en; + // Group Updated Message + group_name_changed: '{{name}} changed the group name to "{{newValue}}".', + group_member_joined: "{{name}} joined the conversation", + group_member_left: "{{name}} left the conversation", + group_photo_changed: "{{name}} changed the group photo.", + group_description_changed: + '{{name}} changed the group description to "{{newValue}}".', + group_name_changed_to: '{{name}} changed the group name to "{{newValue}}".', +}; export type Translations = typeof en; From fe9534e3dc0eac3db7c44f40386820da8567f805 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Fri, 4 Oct 2024 14:13:56 -0400 Subject: [PATCH 2/5] Add Tests --- .../PressableProfileWithText.test.tsx | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 components/__tests__/PressableProfileWithText.test.tsx diff --git a/components/__tests__/PressableProfileWithText.test.tsx b/components/__tests__/PressableProfileWithText.test.tsx new file mode 100644 index 000000000..f0142ff5a --- /dev/null +++ b/components/__tests__/PressableProfileWithText.test.tsx @@ -0,0 +1,72 @@ +import { PressableProfileWithText } from "@components/PressableProfileWithText"; +import { render, fireEvent } from "@testing-library/react-native"; +import { navigate } from "@utils/navigation"; + +// Mock the navigate function from @utils/navigation +jest.mock("@utils/navigation", () => ({ + navigate: jest.fn(), +})); + +describe("PressableProfileWithTextInner", () => { + const profileAddress = "0x123"; + const profileDisplay = "User123"; + const text = "Hello User123, welcome!"; + const textStyle = { fontSize: 18 }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders ParsedText with the correct text and style", () => { + const { getByText } = render( + + ); + + const renderedText = getByText(text); + expect(renderedText).toBeTruthy(); + expect(renderedText.props.style).toEqual(textStyle); + }); + + it("calls navigate to profile on profileDisplay press", () => { + const { getByText } = render( + + ); + + const parsedText = getByText(profileDisplay); + + // Simulate the press on the profileDisplay text + fireEvent.press(parsedText); + + expect(navigate).toHaveBeenCalledWith("Profile", { + address: profileAddress, + }); + }); + + it("does not call navigate when profileAddress is undefined", () => { + const { getByText } = render( + + ); + + const parsedText = getByText(profileDisplay); + + // Simulate the press on the profileDisplay text + fireEvent.press(parsedText); + + expect(navigate).not.toHaveBeenCalled(); + }); +}); From f9c350d276828b9e2776ce1420309b5d008c68ec Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Fri, 4 Oct 2024 14:49:08 -0400 Subject: [PATCH 3/5] Correct styles on pressables --- components/PressableProfileWithText.tsx | 6 ++-- .../PressableProfileWithText.test.tsx | 32 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/components/PressableProfileWithText.tsx b/components/PressableProfileWithText.tsx index fd452adfc..d5f884f8a 100644 --- a/components/PressableProfileWithText.tsx +++ b/components/PressableProfileWithText.tsx @@ -8,11 +8,13 @@ const PressableProfileWithTextInner = ({ profileDisplay, text, textStyle, + pressableTextStyle, }: { text: string; profileDisplay: string; profileAddress: string; textStyle?: StyleProp; + pressableTextStyle?: StyleProp; }) => { const onPress = useCallback(() => { if (profileAddress) { @@ -32,10 +34,10 @@ const PressableProfileWithTextInner = ({ { onPress, pattern, - style: textStyle, + style: pressableTextStyle, }, ], - [onPress, pattern, textStyle] + [onPress, pattern, pressableTextStyle] ); return ( diff --git a/components/__tests__/PressableProfileWithText.test.tsx b/components/__tests__/PressableProfileWithText.test.tsx index f0142ff5a..0ebda2035 100644 --- a/components/__tests__/PressableProfileWithText.test.tsx +++ b/components/__tests__/PressableProfileWithText.test.tsx @@ -12,6 +12,7 @@ describe("PressableProfileWithTextInner", () => { const profileDisplay = "User123"; const text = "Hello User123, welcome!"; const textStyle = { fontSize: 18 }; + const pressableTextStyle = { fontSize: 18, color: "blue" }; beforeEach(() => { jest.clearAllMocks(); @@ -69,4 +70,35 @@ describe("PressableProfileWithTextInner", () => { expect(navigate).not.toHaveBeenCalled(); }); + + it("renders with the provided style", () => { + const { getByText } = render( + + ); + + const parsedText = getByText(profileDisplay); + + expect(parsedText.props.style).toEqual([textStyle, undefined]); + }); + + it("renders with the provided style for pressable text", () => { + const { getByText } = render( + + ); + + const parsedText = getByText(profileDisplay); + + expect(parsedText.props.style).toEqual([textStyle, pressableTextStyle]); + }); }); From f61cb9bd8aea01b52ec3d325a64bcd7e585b9833 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Fri, 4 Oct 2024 15:54:36 -0400 Subject: [PATCH 4/5] Update pressable style --- components/Chat/ChatGroupUpdatedMessage.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/Chat/ChatGroupUpdatedMessage.tsx b/components/Chat/ChatGroupUpdatedMessage.tsx index c73b511a7..5bbe50858 100644 --- a/components/Chat/ChatGroupUpdatedMessage.tsx +++ b/components/Chat/ChatGroupUpdatedMessage.tsx @@ -111,6 +111,7 @@ export default function ChatGroupUpdatedMessage({ profileDisplay={a.readableName} profileAddress={a.address} textStyle={styles.groupChange} + pressableTextStyle={styles.profileStyle} /> ))} @@ -134,5 +135,8 @@ const useStyles = () => { marginBottom: 9, paddingHorizontal: 24, }, + profileStyle: { + fontWeight: "bold", + }, }); }; From a64e62dd369c1854114fe041a585e353353dc758 Mon Sep 17 00:00:00 2001 From: Alex Risch Date: Tue, 15 Oct 2024 14:22:54 -0400 Subject: [PATCH 5/5] update to design system Fixed tsconfig Added util to create text styles Updated Chat Group Updated message to match design system Added ParsedText component --- components/Chat/ChatGroupUpdatedMessage.tsx | 32 +++++++++++------ components/Chat/Message/Message.tsx | 2 +- components/ParsedText/ParsedText.props.ts | 8 +++++ components/ParsedText/ParsedText.tsx | 35 ++++++++++++++++++ components/PressableProfileWithText.tsx | 39 +++++++++++---------- design-system/Text/Text.props.ts | 32 +++++++++-------- design-system/Text/Text.tsx | 22 ++++-------- design-system/Text/Text.utils.ts | 26 ++++++++++++++ tsconfig.json | 4 +-- 9 files changed, 139 insertions(+), 61 deletions(-) create mode 100644 components/ParsedText/ParsedText.props.ts create mode 100644 components/ParsedText/ParsedText.tsx create mode 100644 design-system/Text/Text.utils.ts diff --git a/components/Chat/ChatGroupUpdatedMessage.tsx b/components/Chat/ChatGroupUpdatedMessage.tsx index 5bbe50858..f30d1e5e3 100644 --- a/components/Chat/ChatGroupUpdatedMessage.tsx +++ b/components/Chat/ChatGroupUpdatedMessage.tsx @@ -1,8 +1,13 @@ import { PressableProfileWithText } from "@components/PressableProfileWithText"; +import { InboxIdStoreType } from "@data/store/inboxIdStore"; +import { ProfilesStoreType } from "@data/store/profilesStore"; +import { useSelect } from "@data/store/storeHelpers"; +import { VStack } from "@design-system/VStack"; import { translate } from "@i18n"; import { textSecondaryColor } from "@styles/colors"; +import { navigate } from "@utils/navigation"; import { GroupUpdatedContent } from "@xmtp/react-native-sdk"; -import { useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { StyleSheet, useColorScheme } from "react-native"; import { MessageToDisplay } from "./Message/Message"; @@ -12,14 +17,16 @@ import { } from "../../data/store/accountsStore"; import { getPreferredName, getProfile } from "../../utils/profile"; -export default function ChatGroupUpdatedMessage({ +const inboxIdStoreSelectedKeys: (keyof InboxIdStoreType)[] = ["byInboxId"]; +const profilesStoreSelectedKeys: (keyof ProfilesStoreType)[] = ["profiles"]; +export function ChatGroupUpdatedMessage({ message, }: { message: MessageToDisplay; }) { const styles = useStyles(); - const byInboxId = useInboxIdStore().byInboxId; - const profiles = useProfilesStore().profiles; + const { byInboxId } = useInboxIdStore(useSelect(inboxIdStoreSelectedKeys)); + const { profiles } = useProfilesStore(useSelect(profilesStoreSelectedKeys)); // JSON Parsing is heavy so useMemo const parsedContent = useMemo( () => JSON.parse(message.content) as GroupUpdatedContent, @@ -102,19 +109,24 @@ export default function ChatGroupUpdatedMessage({ } }); + const onPress = useCallback((address: string) => { + return navigate("Profile", { + address, + }); + }, []); + return ( - <> + {membersActions.map((a) => ( ))} - + ); } @@ -122,9 +134,9 @@ const useStyles = () => { const colorScheme = useColorScheme(); return StyleSheet.create({ textContainer: { - flexDirection: "row", + justifyContent: "center", alignItems: "center", - textAlign: "center", + width: "100%", }, groupChange: { color: textSecondaryColor(colorScheme), diff --git a/components/Chat/Message/Message.tsx b/components/Chat/Message/Message.tsx index 227228512..949d5c054 100644 --- a/components/Chat/Message/Message.tsx +++ b/components/Chat/Message/Message.tsx @@ -63,7 +63,7 @@ import Avatar from "../../Avatar"; import ClickableText from "../../ClickableText"; import ActionButton from "../ActionButton"; import AttachmentMessagePreview from "../Attachment/AttachmentMessagePreview"; -import ChatGroupUpdatedMessage from "../ChatGroupUpdatedMessage"; +import { ChatGroupUpdatedMessage } from "../ChatGroupUpdatedMessage"; import FramesPreviews from "../Frame/FramesPreviews"; import ChatInputReplyBubble from "../Input/InputReplyBubble"; import TransactionPreview from "../Transaction/TransactionPreview"; diff --git a/components/ParsedText/ParsedText.props.ts b/components/ParsedText/ParsedText.props.ts new file mode 100644 index 000000000..552ec4fa9 --- /dev/null +++ b/components/ParsedText/ParsedText.props.ts @@ -0,0 +1,8 @@ +import { ITextProps } from "@design-system/Text"; +import { ITextStyleProps } from "@design-system/Text/Text.props"; +import { ParseShape } from "react-native-parsed-text"; + +export interface IParsedTextProps extends ITextProps { + parse: ParseShape[]; + pressableStyle?: ITextStyleProps; +} diff --git a/components/ParsedText/ParsedText.tsx b/components/ParsedText/ParsedText.tsx new file mode 100644 index 000000000..71450ffd1 --- /dev/null +++ b/components/ParsedText/ParsedText.tsx @@ -0,0 +1,35 @@ +import { getTextStyle } from "@design-system/Text/Text.utils"; +import { useAppTheme } from "@theme/useAppTheme"; +import React, { forwardRef, memo, useMemo } from "react"; +import RNParsedText from "react-native-parsed-text"; + +import { IParsedTextProps } from "./ParsedText.props"; + +const ParsedTextInner = forwardRef( + (props, ref) => { + const { themed } = useAppTheme(); + const styles = getTextStyle(themed, props); + const childThemedProps = useMemo(() => { + return { + ...props, + ...props.pressableStyle, + }; + }, [props]); + const pressableStyles = getTextStyle(themed, childThemedProps); + const parseOptions = useMemo( + () => + props.parse.map(({ onPress, ...rest }) => ({ + ...rest, + onPress, + style: pressableStyles, + })), + [props.parse, pressableStyles] + ); + + return ( + + ); + } +); + +export const ParsedText = memo(ParsedTextInner); diff --git a/components/PressableProfileWithText.tsx b/components/PressableProfileWithText.tsx index d5f884f8a..e2d81f839 100644 --- a/components/PressableProfileWithText.tsx +++ b/components/PressableProfileWithText.tsx @@ -1,29 +1,26 @@ -import { navigate } from "@utils/navigation"; +import { ITextStyleProps } from "@design-system/Text/Text.props"; import { memo, useCallback, useMemo } from "react"; -import { StyleProp, TextStyle } from "react-native"; -import ParsedText from "react-native-parsed-text"; + +import { ParsedText } from "./ParsedText/ParsedText"; + +const pressableStyle: ITextStyleProps = { + weight: "bold", +}; const PressableProfileWithTextInner = ({ profileAddress, profileDisplay, text, - textStyle, - pressableTextStyle, + onPress, }: { + onPress: (address: string) => void; text: string; profileDisplay: string; profileAddress: string; - textStyle?: StyleProp; - pressableTextStyle?: StyleProp; }) => { - const onPress = useCallback(() => { - if (profileAddress) { - return navigate("Profile", { - address: profileAddress, - }); - } - return undefined; - }, [profileAddress]); + const handlePress = useCallback(() => { + return onPress(profileAddress); + }, [profileAddress, onPress]); const pattern = useMemo( () => new RegExp(profileDisplay, "g"), @@ -32,16 +29,20 @@ const PressableProfileWithTextInner = ({ const parseOptions = useMemo( () => [ { - onPress, + onPress: handlePress, pattern, - style: pressableTextStyle, }, ], - [onPress, pattern, pressableTextStyle] + [handlePress, pattern] ); return ( - + {text} ); diff --git a/design-system/Text/Text.props.ts b/design-system/Text/Text.props.ts index d218ac134..9328c4e87 100644 --- a/design-system/Text/Text.props.ts +++ b/design-system/Text/Text.props.ts @@ -7,20 +7,8 @@ import { typography } from "../../theme"; export type ISizes = keyof typeof textSizeStyles; export type IWeights = keyof typeof typography.primary; -export interface ITextProps extends RNTextProps { - /** - * Text which is looked up via i18n. - */ - tx?: TxKeyPath; - /** - * The text to display if not using `tx` or nested components. - */ - text?: string; - /** - * Optional options to pass to i18n. Useful for interpolation - * as well as explicitly setting locale or translation fallbacks. - */ - txOptions?: i18n.TranslateOptions; + +export interface ITextStyleProps { /** * An optional style override useful for padding & margin. */ @@ -37,6 +25,22 @@ export interface ITextProps extends RNTextProps { * Text size modifier. */ size?: ISizes; +} + +export interface ITextProps extends RNTextProps, ITextStyleProps { + /** + * Text which is looked up via i18n. + */ + tx?: TxKeyPath; + /** + * The text to display if not using `tx` or nested components. + */ + text?: string; + /** + * Optional options to pass to i18n. Useful for interpolation + * as well as explicitly setting locale or translation fallbacks. + */ + txOptions?: i18n.TranslateOptions; /** * Children components. */ diff --git a/design-system/Text/Text.tsx b/design-system/Text/Text.tsx index 9b2dd1a29..d38efe788 100644 --- a/design-system/Text/Text.tsx +++ b/design-system/Text/Text.tsx @@ -1,13 +1,8 @@ import React from "react"; import { Text as RNText, StyleProp, TextStyle } from "react-native"; -import { IPresets, presets } from "./Text.presets"; import { ITextProps } from "./Text.props"; -import { - textFontWeightStyles, - textRtlStyle, - textSizeStyles, -} from "./Text.styles"; +import { getTextStyle } from "./Text.utils"; import { translate } from "../../i18n"; import { useAppTheme } from "../../theme/useAppTheme"; @@ -35,15 +30,12 @@ export const Text = React.forwardRef((props, ref) => { const i18nText = tx && translate(tx, txOptions); const content = i18nText || text || children; - const preset: IPresets = props.preset ?? "default"; - - const styles: StyleProp = [ - textRtlStyle, - themed(presets[preset]), - weight && textFontWeightStyles[weight], - size && textSizeStyles[size], - styleProp, - ]; + const styles: StyleProp = getTextStyle(themed, { + weight, + size, + style: styleProp, + ...props, + }); return ( diff --git a/design-system/Text/Text.utils.ts b/design-system/Text/Text.utils.ts new file mode 100644 index 000000000..57a6c2919 --- /dev/null +++ b/design-system/Text/Text.utils.ts @@ -0,0 +1,26 @@ +import { useAppTheme } from "@theme/useAppTheme"; +import { StyleProp, TextStyle } from "react-native"; + +import { IPresets, presets } from "./Text.presets"; +import { ITextStyleProps } from "./Text.props"; +import { + textFontWeightStyles, + textRtlStyle, + textSizeStyles, +} from "./Text.styles"; + +export const getTextStyle = ( + themed: ReturnType["themed"], + { weight, size, style: styleProp, ...props }: ITextStyleProps +): StyleProp => { + const preset: IPresets = props.preset ?? "default"; + const $styles: StyleProp = [ + textRtlStyle, + themed(presets[preset]), + weight && textFontWeightStyles[weight], + size && textSizeStyles[size], + styleProp, + ]; + + return $styles; +}; diff --git a/tsconfig.json b/tsconfig.json index e31d123f1..9a7bc989c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,8 +15,8 @@ "@screens/*": ["./screens/*"], "@styles/*": ["./styles/*"], "@utils/*": ["./utils/*"], - "@theme": ["./theme/*"], - "@design-system": ["./design-system/*"] + "@theme/*": ["./theme/*"], + "@design-system/*": ["./design-system/*"] } }, "exclude": ["./vendor/**/*", "./node_modules/**/*", "./dist/**/*"]