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 6 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",
},
Comment on lines +150 to +152
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not used? 😮

I checked because we have a weight props for Text so we can just use this instead

});
};
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 {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we're extending our text props but the component rendered is not our Text component? Maybe I'm missing something but I don't think it's a good pattern.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that the lib as ParsedTextProps so maybe we should extend that instead? I see parse is optional but we can make it required for us.

parse: ParseShape[];
pressableStyle?: ITextStyleProps;
}
Comment on lines +1 to +8
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO we shouldn't create a new file for props if they are small

24 changes: 24 additions & 0 deletions components/ParsedText/ParsedText.tsx
Original file line number Diff line number Diff line change
@@ -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<ParsedTextProps["parse"]>;
pressableStyle?: RNTextProps["style"];
};

export const ParsedText = memo(
forwardRef<RNParsedText, IParsedTextProps>((props, ref) => {
const { parse, style, pressableStyle, ...rest } = props;

const parseOptions = parse.map(({ onPress, ...rest }) => ({
...rest,
onPress,
style: StyleSheet.flatten([style, pressableStyle]),
}));

return (
<RNParsedText style={style} parse={parseOptions} ref={ref} {...rest} />
);
})
);
61 changes: 61 additions & 0 deletions components/PressableProfileWithText.tsx
Original file line number Diff line number Diff line change
@@ -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]
);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love having a space between things

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 (
<ParsedText
parse={parseOptions}
pressableStyle={pressableStyle}
style={textStyle}
>
{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}
/>
);

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}
/>
);

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}
/>
);

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}
/>
);

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}
pressableTextStyle={pressableTextStyle}
/>
);

const parsedText = getByText(profileDisplay);

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