diff --git a/components/Chat/ChatGroupUpdatedMessage.tsx b/components/Chat/ChatGroupUpdatedMessage.tsx index 1dd1b49ba..f30d1e5e3 100644 --- a/components/Chat/ChatGroupUpdatedMessage.tsx +++ b/components/Chat/ChatGroupUpdatedMessage.tsx @@ -1,7 +1,14 @@ +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 { StyleSheet, Text, useColorScheme } from "react-native"; +import { useCallback, useMemo } from "react"; +import { StyleSheet, useColorScheme } from "react-native"; import { MessageToDisplay } from "./Message/Message"; import { @@ -10,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, @@ -26,11 +35,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 +54,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,38 +71,73 @@ 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, + }); } }); + const onPress = useCallback((address: string) => { + return navigate("Profile", { + address, + }); + }, []); + return ( - <> + {membersActions.map((a) => ( - - {a} - + ))} - + ); } const useStyles = () => { const colorScheme = useColorScheme(); return StyleSheet.create({ + textContainer: { + justifyContent: "center", + alignItems: "center", + width: "100%", + }, groupChange: { color: textSecondaryColor(colorScheme), fontSize: 11, @@ -92,5 +147,8 @@ const useStyles = () => { marginBottom: 9, paddingHorizontal: 24, }, + profileStyle: { + fontWeight: "bold", + }, }); }; 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..e839e4500 --- /dev/null +++ b/components/ParsedText/ParsedText.tsx @@ -0,0 +1,24 @@ +import React, { forwardRef, memo } from "react"; +import { TextProps as RNTextProps, StyleSheet } from "react-native"; +import RNParsedText, { ParsedTextProps } from "react-native-parsed-text"; + +type IParsedTextProps = ParsedTextProps & { + parse: NonNullable; + pressableStyle?: RNTextProps["style"]; +}; + +export const ParsedText = memo( + forwardRef((props, ref) => { + const { parse, style, pressableStyle, ...rest } = props; + + const parseOptions = parse.map(({ onPress, ...rest }) => ({ + ...rest, + onPress, + style: StyleSheet.flatten([style, pressableStyle]), + })); + + return ( + + ); + }) +); diff --git a/components/PressableProfileWithText.tsx b/components/PressableProfileWithText.tsx new file mode 100644 index 000000000..61b4157f0 --- /dev/null +++ b/components/PressableProfileWithText.tsx @@ -0,0 +1,61 @@ +import { memo, useCallback, useMemo } from "react"; + +import { textFontWeightStyles } from "../design-system/Text/Text.styles"; +import { getTextStyle } from "../design-system/Text/Text.utils"; +import { useAppTheme } from "../theme/useAppTheme"; +import { ParsedText } from "./ParsedText/ParsedText"; + +const PressableProfileWithTextInner = ({ + profileAddress, + profileDisplay, + text, + onPress, +}: { + onPress: (address: string) => void; + text: string; + profileDisplay: string; + profileAddress: string; +}) => { + const handlePress = useCallback(() => { + return onPress(profileAddress); + }, [profileAddress, onPress]); + + const pattern = useMemo( + () => new RegExp(profileDisplay, "g"), + [profileDisplay] + ); + const parseOptions = useMemo( + () => [ + { + onPress: handlePress, + pattern, + }, + ], + [handlePress, pattern] + ); + + const { themed } = useAppTheme(); + + const pressableStyle = { + ...textFontWeightStyles.bold, + }; + + const textStyle = getTextStyle(themed, { + ...pressableStyle, + weight: "bold", + preset: "subheading", + size: "xxs", + }); + + return ( + + {text} + + ); +}; + +export const PressableProfileWithText = memo(PressableProfileWithTextInner); diff --git a/components/__tests__/PressableProfileWithText.test.tsx b/components/__tests__/PressableProfileWithText.test.tsx new file mode 100644 index 000000000..0ebda2035 --- /dev/null +++ b/components/__tests__/PressableProfileWithText.test.tsx @@ -0,0 +1,104 @@ +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 }; + const pressableTextStyle = { fontSize: 18, color: "blue" }; + + 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(); + }); + + 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]); + }); +}); 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..c75a1d6e8 --- /dev/null +++ b/design-system/Text/Text.utils.ts @@ -0,0 +1,26 @@ +import { IThemed } 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: IThemed, + { 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/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; diff --git a/theme/useAppTheme.ts b/theme/useAppTheme.ts index 6abcb81a2..9be47e018 100644 --- a/theme/useAppTheme.ts +++ b/theme/useAppTheme.ts @@ -135,6 +135,8 @@ interface UseAppThemeValue { ) => T; } +export type IThemed = ReturnType["themed"]; + /** * Custom hook that provides the app theme and utility functions for theming. * 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/**/*"]