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/**/*"]