Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Pressable Group Updates #927

Open
wants to merge 5 commits into
base: release/2.0.7
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 81 additions & 23 deletions components/Chat/ChatGroupUpdatedMessage.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand All @@ -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];
Expand All @@ -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
Expand All @@ -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 (
<>
<VStack style={styles.textContainer}>
{membersActions.map((a) => (
<Text key={a} style={styles.groupChange}>
{a}
</Text>
<PressableProfileWithText
key={a.address}
text={a.content}
profileDisplay={a.readableName}
profileAddress={a.address}
onPress={onPress}
/>
))}
</>
</VStack>
);
}

const useStyles = () => {
const colorScheme = useColorScheme();
return StyleSheet.create({
textContainer: {
justifyContent: "center",
alignItems: "center",
width: "100%",
},
groupChange: {
color: textSecondaryColor(colorScheme),
fontSize: 11,
Expand All @@ -92,5 +147,8 @@ const useStyles = () => {
marginBottom: 9,
paddingHorizontal: 24,
},
profileStyle: {
fontWeight: "bold",
},
});
};
2 changes: 1 addition & 1 deletion components/Chat/Message/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
8 changes: 8 additions & 0 deletions components/ParsedText/ParsedText.props.ts
Original file line number Diff line number Diff line change
@@ -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;
}
35 changes: 35 additions & 0 deletions components/ParsedText/ParsedText.tsx
Original file line number Diff line number Diff line change
@@ -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<RNParsedText, IParsedTextProps>(
(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 (
<RNParsedText {...props} parse={parseOptions} ref={ref} style={styles} />
);
}
);

export const ParsedText = memo(ParsedTextInner);
51 changes: 51 additions & 0 deletions components/PressableProfileWithText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { ITextStyleProps } from "@design-system/Text/Text.props";
import { memo, useCallback, useMemo } from "react";

import { ParsedText } from "./ParsedText/ParsedText";

const pressableStyle: ITextStyleProps = {
weight: "bold",
};

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]
);

return (
<ParsedText
preset="subheading"
size="xxs"
pressableStyle={pressableStyle}
parse={parseOptions}
>
{text}
</ParsedText>
);
};

export const PressableProfileWithText = memo(PressableProfileWithTextInner);
104 changes: 104 additions & 0 deletions components/__tests__/PressableProfileWithText.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<PressableProfileWithText
profileAddress={profileAddress}
profileDisplay={profileDisplay}
text={text}
textStyle={textStyle}

Check failure on line 27 in components/__tests__/PressableProfileWithText.test.tsx

View workflow job for this annotation

GitHub Actions / tsc

Type '{ profileAddress: string; profileDisplay: string; text: string; textStyle: { fontSize: number; }; }' is not assignable to type 'IntrinsicAttributes & { onPress: (address: string) => void; text: string; profileDisplay: string; profileAddress: string; }'.
/>
);

const renderedText = getByText(text);
expect(renderedText).toBeTruthy();
expect(renderedText.props.style).toEqual(textStyle);
});

it("calls navigate to profile on profileDisplay press", () => {
const { getByText } = render(
<PressableProfileWithText
profileAddress={profileAddress}
profileDisplay={profileDisplay}
text={text}
textStyle={textStyle}

Check failure on line 42 in components/__tests__/PressableProfileWithText.test.tsx

View workflow job for this annotation

GitHub Actions / tsc

Type '{ profileAddress: string; profileDisplay: string; text: string; textStyle: { fontSize: number; }; }' is not assignable to type 'IntrinsicAttributes & { onPress: (address: string) => void; text: string; profileDisplay: string; profileAddress: string; }'.
/>
);

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(
<PressableProfileWithText
profileAddress={undefined as any}
profileDisplay={profileDisplay}
text={text}
textStyle={textStyle}

Check failure on line 62 in components/__tests__/PressableProfileWithText.test.tsx

View workflow job for this annotation

GitHub Actions / tsc

Type '{ profileAddress: any; profileDisplay: string; text: string; textStyle: { fontSize: number; }; }' is not assignable to type 'IntrinsicAttributes & { onPress: (address: string) => void; text: string; profileDisplay: string; profileAddress: string; }'.
/>
);

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(
<PressableProfileWithText
profileAddress={profileAddress}
profileDisplay={profileDisplay}
text={text}
textStyle={textStyle}

Check failure on line 80 in components/__tests__/PressableProfileWithText.test.tsx

View workflow job for this annotation

GitHub Actions / tsc

Type '{ profileAddress: string; profileDisplay: string; text: string; textStyle: { fontSize: number; }; }' is not assignable to type 'IntrinsicAttributes & { onPress: (address: string) => void; text: string; profileDisplay: string; profileAddress: string; }'.
/>
);

const parsedText = getByText(profileDisplay);

expect(parsedText.props.style).toEqual([textStyle, undefined]);
});

it("renders with the provided style for pressable text", () => {
const { getByText } = render(
<PressableProfileWithText
profileAddress={profileAddress}
profileDisplay={profileDisplay}
text={text}
textStyle={textStyle}

Check failure on line 95 in components/__tests__/PressableProfileWithText.test.tsx

View workflow job for this annotation

GitHub Actions / tsc

Type '{ profileAddress: string; profileDisplay: string; text: string; textStyle: { fontSize: number; }; pressableTextStyle: { fontSize: number; color: string; }; }' is not assignable to type 'IntrinsicAttributes & { onPress: (address: string) => void; text: string; profileDisplay: string; profileAddress: string; }'.
pressableTextStyle={pressableTextStyle}
/>
);

const parsedText = getByText(profileDisplay);

expect(parsedText.props.style).toEqual([textStyle, pressableTextStyle]);
});
});
Loading
Loading