Skip to content

Commit 8498e4a

Browse files
Youssefwafa7Exo1iim-saif
authored
fix: fix bio parsing with mentions handling - [CU-869b6djqx] (#106)
Co-authored-by: Youssef Noser <121557650+Exo1i@users.noreply.github.com> Co-authored-by: Saif <78622218+im-saif@users.noreply.github.com>
1 parent 261cc54 commit 8498e4a

4 files changed

Lines changed: 113 additions & 110 deletions

File tree

src/__tests__/components/profile/ProfileInfo.test.tsx

Lines changed: 26 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,11 @@ describe('Profile Info Component', () => {
9191

9292
const { getByText } = render(<ProfileInfo profile={profile} />);
9393

94-
expect(getByText('@janedoe ')).toBeTruthy();
94+
expect(getByText('@janedoe')).toBeTruthy();
9595
});
9696

9797
it('navigates to user profile when mention is clicked', () => {
98+
const mockOnPressMention = jest.fn();
9899
const profile = {
99100
...mockProfile,
100101
bio: 'Hello @janedoe',
@@ -104,12 +105,14 @@ describe('Profile Info Component', () => {
104105
},
105106
};
106107

107-
const { getAllByText } = render(<ProfileInfo profile={profile} />);
108+
const { getByText } = render(
109+
<ProfileInfo profile={profile} onPressMention={mockOnPressMention} />
110+
);
108111

109-
const mentions = getAllByText('@janedoe ');
110-
fireEvent.press(mentions[0]); // Press the first one (the link)
112+
const mention = getByText('@janedoe');
113+
fireEvent.press(mention);
111114

112-
expect(mockPush).toHaveBeenCalledWith('UserProfile', { username: 'janedoe' });
115+
expect(mockOnPressMention).toHaveBeenCalledWith('janedoe');
113116
});
114117

115118
it('renders bio with hashtags as links', () => {
@@ -125,13 +128,10 @@ describe('Profile Info Component', () => {
125128
},
126129
};
127130

128-
const { getAllByText } = render(<ProfileInfo profile={profile} />);
129-
130-
const codingHashtags = getAllByText('#Coding ');
131-
const techHashtags = getAllByText('#Tech ');
131+
const { getByText } = render(<ProfileInfo profile={profile} />);
132132

133-
expect(codingHashtags.length).toBeGreaterThan(0);
134-
expect(techHashtags.length).toBeGreaterThan(0);
133+
expect(getByText('#Coding')).toBeTruthy();
134+
expect(getByText('#Tech')).toBeTruthy();
135135
});
136136

137137
it('renders bio with mixed mentions and hashtags', () => {
@@ -144,15 +144,10 @@ describe('Profile Info Component', () => {
144144
},
145145
};
146146

147-
const { getAllByText } = render(<ProfileInfo profile={profile} />);
148-
149-
const mentions = getAllByText('@janedoe ');
150-
expect(mentions.length).toBeGreaterThan(0);
151-
152-
const hashtags = getAllByText('#Coding ');
153-
expect(hashtags.length).toBeGreaterThan(0);
147+
const { getByText } = render(<ProfileInfo profile={profile} />);
154148

155-
// Note: "check " is part of a larger text segment, not a separate text node
149+
expect(getByText('@janedoe')).toBeTruthy();
150+
expect(getByText('#Coding')).toBeTruthy();
156151
});
157152

158153
it('handles bio with no entities', () => {
@@ -192,12 +187,10 @@ describe('Profile Info Component', () => {
192187
},
193188
};
194189

195-
const { getByText, getAllByText } = render(<ProfileInfo profile={profile} />);
190+
const { getByText } = render(<ProfileInfo profile={profile} />);
196191

197192
expect(getByText('Text with hashtag ')).toBeTruthy();
198-
199-
const hashtags = getAllByText('#Test ');
200-
expect(hashtags.length).toBeGreaterThan(0);
193+
expect(getByText('#Test')).toBeTruthy();
201194
});
202195

203196
it('handles bio with undefined hashtags in bioEntities', () => {
@@ -210,12 +203,10 @@ describe('Profile Info Component', () => {
210203
},
211204
};
212205

213-
const { getByText, getAllByText } = render(<ProfileInfo profile={profile} />);
206+
const { getByText } = render(<ProfileInfo profile={profile} />);
214207

215208
expect(getByText('Text with mention ')).toBeTruthy();
216-
217-
const mentions = getAllByText('@user ');
218-
expect(mentions.length).toBeGreaterThan(0);
209+
expect(getByText('@user')).toBeTruthy();
219210
});
220211

221212
it('passes correct data to ProfileUser', () => {
@@ -240,12 +231,10 @@ describe('Profile Info Component', () => {
240231
},
241232
};
242233

243-
const { getAllByText } = render(<ProfileInfo profile={profile} />);
244-
245-
const mentions = getAllByText('@janedoe ');
246-
expect(mentions.length).toBeGreaterThan(0);
234+
const { getByText } = render(<ProfileInfo profile={profile} />);
247235

248-
// Note: "hello" is part of the bio text that follows the mention
236+
expect(getByText('@janedoe')).toBeTruthy();
237+
expect(getByText(' hello')).toBeTruthy();
249238
});
250239

251240
it('handles bio entities at the end of the bio', () => {
@@ -258,12 +247,10 @@ describe('Profile Info Component', () => {
258247
},
259248
};
260249

261-
const { getByText, getAllByText } = render(<ProfileInfo profile={profile} />);
250+
const { getByText } = render(<ProfileInfo profile={profile} />);
262251

263252
expect(getByText('hello ')).toBeTruthy();
264-
265-
const mentions = getAllByText('@janedoe ');
266-
expect(mentions.length).toBeGreaterThan(0);
253+
expect(getByText('@janedoe')).toBeTruthy();
267254
});
268255

269256
it('sorts entities by start position', () => {
@@ -276,14 +263,9 @@ describe('Profile Info Component', () => {
276263
},
277264
};
278265

279-
const { getAllByText } = render(<ProfileInfo profile={profile} />);
280-
281-
const hashtags = getAllByText('#Tech ');
282-
expect(hashtags.length).toBeGreaterThan(0);
283-
284-
const mentions = getAllByText('@janedoe ');
285-
expect(mentions.length).toBeGreaterThan(0);
266+
const { getByText } = render(<ProfileInfo profile={profile} />);
286267

287-
// Entities are sorted and rendered in correct order
268+
expect(getByText('#Tech')).toBeTruthy();
269+
expect(getByText('@janedoe')).toBeTruthy();
288270
});
289271
});

src/components/profile/ProfileHeader.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Alert, Dimensions, StyleSheet, TouchableOpacity, View } from 'react-native';
22

33
import { useNavigation } from '@react-navigation/native';
4+
import { StackNavigationProp } from '@react-navigation/stack';
45

56
import Avatar from '@/components/profile/Avatar';
67
import ProfileBanner from '@/components/profile/Banner';
@@ -9,12 +10,12 @@ import { useFollowMutation } from '@/hooks/profile/useFollowMutation';
910
import { useTheme } from '@/hooks/useTheme';
1011
import { useUserStore } from '@/stores/userStore';
1112
import { colors } from '@/utils/colorTheme';
12-
import { PROFILE } from '@/utils/navigation/routeNames';
13+
import { PROFILE, ROOT } from '@/utils/navigation/routeNames';
1314

1415
import ProfileInfo from './ProfileInfo';
1516
import ProfileStats from './Statistics';
1617

17-
import type { ProfileScreenOptionsProps } from '@/types/navigation';
18+
import type { ProfileScreenOptionsProps, RootStackParamList } from '@/types/navigation';
1819
import type { UserProfile } from '@/types/user';
1920

2021
const { width, height } = Dimensions.get('window');
@@ -31,6 +32,7 @@ const ProfileHeader = ({ username, profile }: ProfileHeaderProps) => {
3132
const styles = getStyles(theme);
3233

3334
const navigation = useNavigation<ProfileScreenProps['navigation']>();
35+
const rootNavigation = useNavigation<StackNavigationProp<RootStackParamList>>();
3436
const authenticatedUsername = useUserStore((state) => state.user.username);
3537
const isAuthenticatedUser = username === authenticatedUsername;
3638
const followMutation = useFollowMutation();
@@ -65,6 +67,13 @@ const ProfileHeader = ({ username, profile }: ProfileHeaderProps) => {
6567
navigation.push(PROFILE.PROFILE_PIC, { uri, username: username, isAvatar });
6668
};
6769

70+
const handleMentionPress = (mentionedUsername: string) => {
71+
rootNavigation.push(ROOT.PROFILE, {
72+
screen: PROFILE.USER_PROFILE,
73+
params: { username: mentionedUsername },
74+
});
75+
};
76+
6877
const userStats = {
6978
followers: profile.followersCount,
7079
following: profile.followingCount,
@@ -112,7 +121,7 @@ const ProfileHeader = ({ username, profile }: ProfileHeaderProps) => {
112121
</View>
113122

114123
<View style={styles.infoContainer}>
115-
<ProfileInfo profile={profile} />
124+
<ProfileInfo profile={profile} onPressMention={handleMentionPress} />
116125
<ProfileStats
117126
stats={userStats}
118127
onFollowersPress={() =>

src/components/profile/ProfileInfo.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,21 @@ type DetailItem = {
1919
};
2020

2121
type ProfileInfoProps = {
22-
profile: UserProfile;
22+
profile: UserProfile | null;
23+
onPressMention?: (username: string) => void;
24+
onPressHashtag?: (hashtag: string) => void;
2325
};
2426

25-
const ProfileInfo = ({ profile }: ProfileInfoProps) => {
26-
const bioElements = useBioParser(profile);
27+
const ProfileInfo = ({ profile, onPressMention, onPressHashtag }: ProfileInfoProps) => {
28+
const bioElements = useBioParser({
29+
bio: profile?.bio,
30+
mentions: profile?.bioEntities?.mentions,
31+
hashtags: profile?.bioEntities?.hashtags,
32+
onPressMention,
33+
onPressHashtag,
34+
});
35+
36+
if (!profile) return null;
2737

2838
const handleOpenLink = async (url: string) => {
2939
try {
@@ -53,7 +63,6 @@ const ProfileInfo = ({ profile }: ProfileInfoProps) => {
5363
console.error('Error opening URL:', error);
5464
}
5565
};
56-
if (!profile) return null;
5766

5867
const dynamicDetails = [
5968
profile.location && {

src/hooks/profile/useBioParser.tsx

Lines changed: 62 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,85 @@
1-
import { useNavigation } from '@react-navigation/native';
2-
import { StackNavigationProp } from '@react-navigation/stack';
3-
41
import AppText from '@/components/ui/AppText';
5-
import { ProfileStackParamList } from '@/types/navigation';
6-
import { UserProfile } from '@/types/user';
7-
import { PROFILE } from '@/utils/navigation/routeNames';
8-
9-
type NavigationProp = StackNavigationProp<ProfileStackParamList>;
2+
import { type HashtagEntity, type MentionEntity } from '@/types/user';
103

11-
type BioEntity = {
12-
type: 'mention' | 'hashtag';
13-
start: number;
14-
value: string;
4+
type BioParserProps = {
5+
bio: string | null | undefined;
6+
mentions?: MentionEntity[];
7+
hashtags?: HashtagEntity[];
8+
onPressMention?: (username: string) => void;
9+
onPressHashtag?: (hashtag: string) => void;
1510
};
1611

17-
export const useBioParser = (profile: UserProfile | undefined) => {
18-
const navigation = useNavigation<NavigationProp>();
19-
20-
if (!profile || !profile?.bio || profile?.bio === '') return null;
21-
22-
const bio = profile.bio;
23-
const mentions = profile.bioEntities?.mentions ?? [];
24-
const hashtags = profile.bioEntities?.hashtags ?? [];
25-
26-
const entities: BioEntity[] = [
12+
function sortBioEntities(mentions: MentionEntity[] = [], hashtags: HashtagEntity[] = []) {
13+
return [
2714
...mentions.map((m) => ({
2815
type: 'mention' as const,
16+
key: `@${m.username}`,
2917
start: m.startPosition,
30-
value: `@${m.username} `,
3118
})),
3219
...hashtags.map((h) => ({
3320
type: 'hashtag' as const,
21+
key: `#${h.hashtag}`,
3422
start: h.startPosition,
35-
value: `#${h.hashtag} `,
3623
})),
3724
].sort((a, b) => a.start - b.start);
25+
}
3826

39-
const elements: React.ReactNode[] = [];
40-
let lastIndex = 0;
27+
export const useBioParser = ({
28+
bio,
29+
mentions,
30+
hashtags,
31+
onPressMention,
32+
onPressHashtag,
33+
}: BioParserProps) => {
34+
if (!bio) return null;
4135

42-
for (let idx = 0; idx < entities.length; idx++) {
43-
const entity = entities[idx];
36+
const ordered = sortBioEntities(mentions, hashtags);
4437

45-
if (entity.start > lastIndex) {
46-
elements.push(
47-
<AppText key={`text-${idx}`} variant="bio">
48-
{bio.slice(lastIndex, entity.start)}
49-
</AppText>
50-
);
51-
}
38+
const parts: {
39+
content: string;
40+
type: 'text' | 'mention' | 'hashtag';
41+
value?: string;
42+
}[] = [];
5243

53-
elements.push(
54-
<AppText
55-
key={`entity-${entity.start}`}
56-
variant="link"
57-
onPress={() => {
58-
if (entity.type === 'mention') {
59-
navigation.push(PROFILE.USER_PROFILE, {
60-
username: entity.value.replace('@', '').trim(),
61-
});
62-
}
63-
// TODO: Handle hashtag navigation
64-
}}
65-
>
66-
{entity.value}
67-
</AppText>
68-
);
44+
let current = 0;
45+
for (const e of ordered) {
46+
const value = e.key;
47+
const len = value.length;
6948

70-
lastIndex = entity.start;
49+
if (e.start > current) parts.push({ content: bio.slice(current, e.start), type: 'text' });
50+
51+
parts.push({ content: bio.slice(e.start, e.start + len), type: e.type, value });
52+
current = e.start + len;
7153
}
7254

73-
if (lastIndex < bio.length) {
74-
elements.push(
75-
<AppText key="text-end" variant="bio">
76-
{bio.slice(lastIndex)}
55+
if (current < bio.length) parts.push({ content: bio.slice(current), type: 'text' });
56+
57+
return parts.map((p, idx) => {
58+
if (p.type === 'mention')
59+
return (
60+
<AppText
61+
key={`m-${idx}`}
62+
variant="link"
63+
onPress={() => onPressMention?.(p.value!.slice(1))}
64+
>
65+
{p.content}
66+
</AppText>
67+
);
68+
if (p.type === 'hashtag')
69+
return (
70+
<AppText
71+
key={`h-${idx}`}
72+
variant="link"
73+
onPress={() => onPressHashtag?.(p.value!.slice(1))}
74+
>
75+
{p.content}
76+
</AppText>
77+
);
78+
79+
return (
80+
<AppText key={`t-${idx}`} variant="bio">
81+
{p.content}
7782
</AppText>
7883
);
79-
}
80-
81-
return elements;
84+
});
8285
};

0 commit comments

Comments
 (0)