react-native-gifted-chat 3.1.4 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -199,7 +199,6 @@ These props moved from `GiftedChatProps` to `MessagesContainerProps` but are sti
199
199
  - `@types/lodash.isequal`: ^4.5.8
200
200
  - `dayjs`: ^1.11.19
201
201
  - `lodash.isequal`: ^4.5.0
202
- - `react-native-autolink`: ^4.2.0
203
202
  - `react-native-zoom-reanimated`: ^1.4.10
204
203
 
205
204
  #### Peer Dependencies (now required)
package/README.md CHANGED
@@ -45,7 +45,7 @@
45
45
  - Composer actions (to attach photos, etc.)
46
46
  - Load earlier messages
47
47
  - Copy messages to clipboard
48
- - Touchable links using [react-native-autolink](https://github.com/joshswan/react-native-autolink)
48
+ - Touchable links with customizable parsing (URLs, emails, phone numbers, hashtags, mentions)
49
49
  - Avatar as user's initials
50
50
  - Localized dates
51
51
  - Multi-line TextInput
@@ -210,6 +210,7 @@ Messages, system messages, quick replies etc.: [data structure](src/Models.ts)
210
210
  - **`onSend`** _(Function)_ - Callback when sending a message
211
211
  - **`messageIdGenerator`** _(Function)_ - Generate an id for new messages. Defaults to a simple random string generator.
212
212
  - **`locale`** _(String)_ - Locale to localize the dates. You need first to import the locale you need (ie. `require('dayjs/locale/de')` or `import 'dayjs/locale/fr'`)
213
+ - **`colorScheme`** _('light' | 'dark')_ - Force color scheme (light/dark mode). When set to `'light'` or `'dark'`, it overrides the system color scheme. When `undefined`, it uses the system color scheme. Default is `undefined`.
213
214
 
214
215
  ### Refs
215
216
 
@@ -268,12 +269,18 @@ Messages, system messages, quick replies etc.: [data structure](src/Models.ts)
268
269
  - **`imageProps`** _(Object)_ - Extra props to be passed to the [`<Image>`](https://reactnative.dev/docs/image) component created by the default `renderMessageImage`
269
270
  - **`imageStyle`** _(Object)_ - Custom style for message images
270
271
  - **`videoProps`** _(Object)_ - Extra props to be passed to the video component created by the required `renderMessageVideo`
271
- - **`messageTextProps`** _(Object)_ - Extra props to be passed to the MessageText component. Useful for customizing link parsing behavior, text styles, and matchers. Supports all [react-native-autolink](https://github.com/joshswan/react-native-autolink) props including:
272
+ - **`messageTextProps`** _(Object)_ - Extra props to be passed to the MessageText component. Useful for customizing link parsing behavior, text styles, and matchers. Supports the following props:
272
273
  - `matchers` - Custom matchers for linking message content (like URLs, phone numbers, hashtags, mentions)
273
274
  - `linkStyle` - Custom style for links
274
275
  - `email` - Enable/disable email parsing (default: true)
275
276
  - `phone` - Enable/disable phone number parsing (default: true)
276
277
  - `url` - Enable/disable URL parsing (default: true)
278
+ - `hashtag` - Enable/disable hashtag parsing (default: false)
279
+ - `mention` - Enable/disable mention parsing (default: false)
280
+ - `hashtagUrl` - Base URL for hashtags (e.g., 'https://twitter.com/hashtag')
281
+ - `mentionUrl` - Base URL for mentions (e.g., 'https://twitter.com')
282
+ - `stripPrefix` - Strip 'http://' or 'https://' from URL display (default: false)
283
+ - `TextComponent` - Custom Text component to use (e.g., from react-native-gesture-handler)
277
284
 
278
285
  Example:
279
286
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-gifted-chat",
3
- "version": "3.1.4",
3
+ "version": "3.2.0",
4
4
  "description": "The most complete chat UI for React Native",
5
5
  "keywords": [
6
6
  "android",
@@ -52,7 +52,6 @@
52
52
  "@types/lodash.isequal": "^4.5.8",
53
53
  "dayjs": "^1.11.19",
54
54
  "lodash.isequal": "^4.5.0",
55
- "react-native-autolink": "^4.2.0",
56
55
  "react-native-zoom-reanimated": "^1.4.10"
57
56
  },
58
57
  "devDependencies": {
@@ -1,7 +1,7 @@
1
1
  import React, { useCallback, useMemo } from 'react'
2
2
  import {
3
3
  View,
4
- Pressable, // don't use Pressable from gesture handler to issues with react-native-autolink (onPress doesn't work)
4
+ Pressable,
5
5
  } from 'react-native'
6
6
 
7
7
  import { Text } from 'react-native-gesture-handler'
package/src/Composer.tsx CHANGED
@@ -5,11 +5,11 @@ import {
5
5
  TextInputChangeEvent,
6
6
  TextInputContentSizeChangeEvent,
7
7
  TextInputProps,
8
- useColorScheme,
9
8
  View,
10
9
  } from 'react-native'
11
10
  import { TextInput } from 'react-native-gesture-handler'
12
11
  import { Color } from './Color'
12
+ import { useColorScheme } from './hooks/useColorScheme'
13
13
  import stylesCommon, { getColorSchemeStyle } from './styles'
14
14
 
15
15
  export interface ComposerProps {
@@ -10,6 +10,7 @@ import React, {
10
10
  import {
11
11
  View,
12
12
  LayoutChangeEvent,
13
+ useColorScheme,
13
14
  } from 'react-native'
14
15
  import {
15
16
  ActionSheetProvider,
@@ -48,6 +49,7 @@ function GiftedChat<TMessage extends IMessage = IMessage> (
48
49
  user = {},
49
50
  onSend,
50
51
  locale = 'en',
52
+ colorScheme: colorSchemeProp,
51
53
  renderLoading,
52
54
  actionSheet,
53
55
  textInputProps,
@@ -56,6 +58,9 @@ function GiftedChat<TMessage extends IMessage = IMessage> (
56
58
  isInverted = true,
57
59
  } = props
58
60
 
61
+ const systemColorScheme = useColorScheme()
62
+ const colorScheme = colorSchemeProp !== undefined ? colorSchemeProp : systemColorScheme
63
+
59
64
  const actionSheetRef = useRef<ActionSheetProviderRef>(null)
60
65
 
61
66
  const messagesContainerRef = useMemo(
@@ -236,8 +241,9 @@ function GiftedChat<TMessage extends IMessage = IMessage> (
236
241
  actionSheetRef.current!.showActionSheetWithOptions,
237
242
  })),
238
243
  getLocale: () => locale,
244
+ getColorScheme: () => colorScheme,
239
245
  }),
240
- [actionSheet, locale]
246
+ [actionSheet, locale, colorScheme]
241
247
  )
242
248
 
243
249
  useEffect(() => {
@@ -45,6 +45,8 @@ export interface GiftedChatProps<TMessage extends IMessage> extends Partial<Mess
45
45
  user?: User
46
46
  /* Locale to localize the dates */
47
47
  locale?: string
48
+ /* Force color scheme (light/dark); default is undefined (uses system color scheme) */
49
+ colorScheme?: 'light' | 'dark'
48
50
  /* Format to use for rendering times; default is 'LT' */
49
51
  timeFormat?: string
50
52
  /* Format to use for rendering dates; default is 'll' */
@@ -11,6 +11,7 @@ export interface IGiftedChatContext {
11
11
  ) => void
12
12
  }
13
13
  getLocale(): string
14
+ getColorScheme(): 'light' | 'dark' | null | undefined
14
15
  }
15
16
 
16
17
  export const GiftedChatContext = createContext<IGiftedChatContext>({
@@ -18,6 +19,7 @@ export const GiftedChatContext = createContext<IGiftedChatContext>({
18
19
  actionSheet: () => ({
19
20
  showActionSheetWithOptions: () => {},
20
21
  }),
22
+ getColorScheme: () => undefined,
21
23
  })
22
24
 
23
25
  export const useChatContext = () => useContext(GiftedChatContext)
@@ -1,9 +1,10 @@
1
1
  import React, { useMemo } from 'react'
2
- import { StyleSheet, View, StyleProp, ViewStyle, useColorScheme } from 'react-native'
2
+ import { StyleSheet, View, StyleProp, ViewStyle } from 'react-native'
3
3
 
4
4
  import { Actions, ActionsProps } from './Actions'
5
5
  import { Color } from './Color'
6
6
  import { Composer, ComposerProps } from './Composer'
7
+ import { useColorScheme } from './hooks/useColorScheme'
7
8
  import { IMessage } from './Models'
8
9
  import { Send, SendProps } from './Send'
9
10
  import { getColorSchemeStyle } from './styles'
@@ -4,7 +4,7 @@ export default StyleSheet.create({
4
4
  container: {
5
5
  flexDirection: 'row',
6
6
  alignItems: 'flex-end',
7
- maxWidth: '80%',
7
+ maxWidth: '70%',
8
8
  },
9
9
  container_left: {
10
10
  justifyContent: 'flex-start',
@@ -7,8 +7,8 @@ import {
7
7
  View,
8
8
  } from 'react-native'
9
9
 
10
- import { Match } from 'autolinker/dist/es2015'
11
- import Autolink, { AutolinkProps } from 'react-native-autolink'
10
+ import { Text } from 'react-native-gesture-handler'
11
+ import { LinkParser, LinkMatcher, LinkType } from './linkParser'
12
12
  import { LeftRightStyle, IMessage } from './Models'
13
13
 
14
14
  export type MessageTextProps<TMessage extends IMessage> = {
@@ -21,9 +21,19 @@ export type MessageTextProps<TMessage extends IMessage> = {
21
21
  onPress?: (
22
22
  message: TMessage,
23
23
  url: string,
24
- match: Match
24
+ type: LinkType
25
25
  ) => void
26
- } & Omit<AutolinkProps, 'text' | 'onPress'>
26
+ // Link parser options
27
+ matchers?: LinkMatcher[]
28
+ email?: boolean
29
+ phone?: boolean
30
+ url?: boolean
31
+ hashtag?: boolean
32
+ mention?: boolean
33
+ hashtagUrl?: string
34
+ mentionUrl?: string
35
+ stripPrefix?: boolean
36
+ }
27
37
 
28
38
  export const MessageText: React.FC<MessageTextProps<IMessage>> = ({
29
39
  currentMessage,
@@ -33,7 +43,15 @@ export const MessageText: React.FC<MessageTextProps<IMessage>> = ({
33
43
  linkStyle: linkStyleProp,
34
44
  customTextStyle,
35
45
  onPress: onPressProp,
36
- ...rest
46
+ matchers,
47
+ email = true,
48
+ phone = true,
49
+ url = true,
50
+ hashtag = false,
51
+ mention = false,
52
+ hashtagUrl,
53
+ mentionUrl,
54
+ stripPrefix = false,
37
55
  }) => {
38
56
  const linkStyle = useMemo(() => StyleSheet.flatten([
39
57
  styles.link,
@@ -46,22 +64,27 @@ export const MessageText: React.FC<MessageTextProps<IMessage>> = ({
46
64
  customTextStyle,
47
65
  ], [position, textStyle, customTextStyle])
48
66
 
49
- const handlePress = useCallback((url: string, match: Match) => {
50
- onPressProp?.(currentMessage, url, match)
67
+ const handlePress = useCallback((url: string, type: LinkType) => {
68
+ onPressProp?.(currentMessage, url, type)
51
69
  }, [onPressProp, currentMessage])
52
70
 
53
71
  return (
54
72
  <View style={[styles.container, containerStyle?.[position]]}>
55
- <Autolink
56
- email
57
- phone
58
- url
59
- stripPrefix={false}
60
- {...rest}
61
- onPress={onPressProp ? handlePress : undefined}
62
- linkStyle={linkStyle}
63
- style={style}
73
+ <LinkParser
64
74
  text={currentMessage!.text}
75
+ matchers={matchers}
76
+ email={email}
77
+ phone={phone}
78
+ url={url}
79
+ hashtag={hashtag}
80
+ mention={mention}
81
+ hashtagUrl={hashtagUrl}
82
+ mentionUrl={mentionUrl}
83
+ stripPrefix={stripPrefix}
84
+ linkStyle={linkStyle}
85
+ textStyle={style}
86
+ onPress={onPressProp ? handlePress : undefined}
87
+ TextComponent={Text}
65
88
  />
66
89
  </View>
67
90
  )
@@ -9,6 +9,7 @@ export default StyleSheet.create({
9
9
  contentContainerStyle: {
10
10
  flexGrow: 1,
11
11
  justifyContent: 'flex-start',
12
+ paddingBottom: 10,
12
13
  },
13
14
  emptyChatContainer: {
14
15
  transform: [{ scaleY: -1 }],
package/src/Send.tsx CHANGED
@@ -4,7 +4,6 @@ import {
4
4
  StyleProp,
5
5
  ViewStyle,
6
6
  TextStyle,
7
- useColorScheme,
8
7
  } from 'react-native'
9
8
  import { Text } from 'react-native-gesture-handler'
10
9
  import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'
@@ -12,6 +11,7 @@ import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-na
12
11
  import { Color } from './Color'
13
12
  import { TouchableOpacity, TouchableOpacityProps } from './components/TouchableOpacity'
14
13
  import { TEST_ID } from './Constant'
14
+ import { useColorScheme } from './hooks/useColorScheme'
15
15
  import { IMessage } from './Models'
16
16
  import { getColorSchemeStyle } from './styles'
17
17
 
@@ -28,3 +28,33 @@ it('should render <GiftedChat/> and compare with snapshot', () => {
28
28
 
29
29
  expect(toJSON()).toMatchSnapshot()
30
30
  })
31
+
32
+ it('should render <GiftedChat/> with light colorScheme and compare with snapshot', () => {
33
+ const { toJSON } = render(
34
+ <GiftedChat
35
+ messages={messages}
36
+ onSend={() => {}}
37
+ user={{
38
+ _id: 1,
39
+ }}
40
+ colorScheme='light'
41
+ />
42
+ )
43
+
44
+ expect(toJSON()).toMatchSnapshot()
45
+ })
46
+
47
+ it('should render <GiftedChat/> with dark colorScheme and compare with snapshot', () => {
48
+ const { toJSON } = render(
49
+ <GiftedChat
50
+ messages={messages}
51
+ onSend={() => {}}
52
+ user={{
53
+ _id: 1,
54
+ }}
55
+ colorScheme='dark'
56
+ />
57
+ )
58
+
59
+ expect(toJSON()).toMatchSnapshot()
60
+ })
@@ -69,15 +69,8 @@ exports[`should render <Bubble /> and compare with snapshot 1`] = `
69
69
  undefined,
70
70
  ]
71
71
  }
72
- user={
73
- {
74
- "_id": 1,
75
- }
76
- }
77
72
  >
78
- <Text>
79
- test
80
- </Text>
73
+ test
81
74
  </Text>
82
75
  </View>
83
76
  <View
@@ -46,3 +46,97 @@ exports[`should render <GiftedChat/> and compare with snapshot 1`] = `
46
46
  </KeyboardProvider>
47
47
  </View>
48
48
  `;
49
+
50
+ exports[`should render <GiftedChat/> with dark colorScheme and compare with snapshot 1`] = `
51
+ <View
52
+ style={
53
+ {
54
+ "flex": 1,
55
+ }
56
+ }
57
+ >
58
+ <KeyboardProvider
59
+ navigationBarTranslucent={true}
60
+ statusBarTranslucent={true}
61
+ >
62
+ <View
63
+ style={
64
+ {
65
+ "flex": 1,
66
+ }
67
+ }
68
+ >
69
+ <View
70
+ behavior="padding"
71
+ style={
72
+ {
73
+ "flex": 1,
74
+ }
75
+ }
76
+ >
77
+ <View
78
+ onLayout={[Function]}
79
+ style={
80
+ [
81
+ {
82
+ "flex": 1,
83
+ },
84
+ {
85
+ "overflow": "hidden",
86
+ },
87
+ ]
88
+ }
89
+ testID="GC_WRAPPER"
90
+ />
91
+ </View>
92
+ </View>
93
+ </KeyboardProvider>
94
+ </View>
95
+ `;
96
+
97
+ exports[`should render <GiftedChat/> with light colorScheme and compare with snapshot 1`] = `
98
+ <View
99
+ style={
100
+ {
101
+ "flex": 1,
102
+ }
103
+ }
104
+ >
105
+ <KeyboardProvider
106
+ navigationBarTranslucent={true}
107
+ statusBarTranslucent={true}
108
+ >
109
+ <View
110
+ style={
111
+ {
112
+ "flex": 1,
113
+ }
114
+ }
115
+ >
116
+ <View
117
+ behavior="padding"
118
+ style={
119
+ {
120
+ "flex": 1,
121
+ }
122
+ }
123
+ >
124
+ <View
125
+ onLayout={[Function]}
126
+ style={
127
+ [
128
+ {
129
+ "flex": 1,
130
+ },
131
+ {
132
+ "overflow": "hidden",
133
+ },
134
+ ]
135
+ }
136
+ testID="GC_WRAPPER"
137
+ />
138
+ </View>
139
+ </View>
140
+ </KeyboardProvider>
141
+ </View>
142
+ `;
@@ -12,7 +12,7 @@ exports[`Message component should render <Message /> and compare with snapshot 1
12
12
  "flexDirection": "row",
13
13
  "justifyContent": "flex-start",
14
14
  "marginLeft": 8,
15
- "maxWidth": "80%",
15
+ "maxWidth": "70%",
16
16
  },
17
17
  {
18
18
  "marginBottom": 10,
@@ -92,15 +92,8 @@ exports[`Message component should render <Message /> and compare with snapshot 1
92
92
  undefined,
93
93
  ]
94
94
  }
95
- user={
96
- {
97
- "_id": 1,
98
- }
99
- }
100
95
  >
101
- <Text>
102
- test
103
- </Text>
96
+ test
104
97
  </Text>
105
98
  </View>
106
99
  <View
@@ -162,7 +155,7 @@ exports[`Message component should render <Message /> with Avatar 1`] = `
162
155
  "flexDirection": "row",
163
156
  "justifyContent": "flex-start",
164
157
  "marginLeft": 8,
165
- "maxWidth": "80%",
158
+ "maxWidth": "70%",
166
159
  },
167
160
  {
168
161
  "marginBottom": 10,
@@ -272,7 +265,6 @@ exports[`Message component should render <Message /> with Avatar 1`] = `
272
265
  }
273
266
  >
274
267
  <Text
275
- isUserAvatarVisible={true}
276
268
  style={
277
269
  [
278
270
  {
@@ -282,15 +274,8 @@ exports[`Message component should render <Message /> with Avatar 1`] = `
282
274
  undefined,
283
275
  ]
284
276
  }
285
- user={
286
- {
287
- "_id": 1,
288
- }
289
- }
290
277
  >
291
- <Text>
292
- test
293
- </Text>
278
+ test
294
279
  </Text>
295
280
  </View>
296
281
  <View
@@ -352,7 +337,7 @@ exports[`Message component should render null if user has no Avatar 1`] = `
352
337
  "flexDirection": "row",
353
338
  "justifyContent": "flex-start",
354
339
  "marginLeft": 8,
355
- "maxWidth": "80%",
340
+ "maxWidth": "70%",
356
341
  },
357
342
  {
358
343
  "marginBottom": 10,
@@ -462,7 +447,6 @@ exports[`Message component should render null if user has no Avatar 1`] = `
462
447
  }
463
448
  >
464
449
  <Text
465
- isUserAvatarVisible={true}
466
450
  style={
467
451
  [
468
452
  {
@@ -472,15 +456,8 @@ exports[`Message component should render null if user has no Avatar 1`] = `
472
456
  undefined,
473
457
  ]
474
458
  }
475
- user={
476
- {
477
- "_id": 1,
478
- }
479
- }
480
459
  >
481
- <Text>
482
- test
483
- </Text>
460
+ test
484
461
  </Text>
485
462
  </View>
486
463
  <View
@@ -23,9 +23,7 @@ exports[`should render <MessageText /> and compare with snapshot 1`] = `
23
23
  ]
24
24
  }
25
25
  >
26
- <Text>
27
- test
28
- </Text>
26
+ test
29
27
  </Text>
30
28
  </View>
31
29
  `;
@@ -59,9 +59,7 @@ exports[`SystemMessage should render <SystemMessage /> and compare with snapshot
59
59
  ]
60
60
  }
61
61
  >
62
- <Text>
63
- test
64
- </Text>
62
+ test
65
63
  </Text>
66
64
  </View>
67
65
  </View>
@@ -0,0 +1,18 @@
1
+ import { useColorScheme as useRNColorScheme } from 'react-native'
2
+ import { useChatContext } from '../GiftedChatContext'
3
+
4
+ /**
5
+ * Custom hook that returns the color scheme from GiftedChat context if provided,
6
+ * otherwise falls back to the system color scheme from React Native.
7
+ *
8
+ * @returns The current color scheme ('light', 'dark', null, or undefined)
9
+ */
10
+ export function useColorScheme() {
11
+ const { getColorScheme } = useChatContext()
12
+ const contextColorScheme = getColorScheme()
13
+ const systemColorScheme = useRNColorScheme()
14
+
15
+ return contextColorScheme !== undefined && contextColorScheme !== null
16
+ ? contextColorScheme
17
+ : systemColorScheme
18
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from './Constant'
3
3
  export * as utils from './utils'
4
4
  export * from './GiftedChatContext'
5
5
  export * from './types'
6
+ export * from './linkParser'
6
7
  export { Actions } from './Actions'
7
8
  export { Avatar } from './Avatar'
8
9
  export { Bubble } from './Bubble'
@@ -20,3 +21,4 @@ export { Time } from './Time'
20
21
  export { GiftedAvatar } from './GiftedAvatar'
21
22
  export { MessageAudio } from './MessageAudio'
22
23
  export { MessageVideo } from './MessageVideo'
24
+ export { useColorScheme } from './hooks/useColorScheme'
@@ -0,0 +1,255 @@
1
+ import React from 'react'
2
+ import { Text, TextStyle, StyleProp, Linking } from 'react-native'
3
+
4
+ export type LinkType = 'url' | 'email' | 'phone' | 'mention' | 'hashtag'
5
+
6
+ export interface ParsedLink {
7
+ type: LinkType
8
+ text: string
9
+ url: string
10
+ index: number
11
+ length: number
12
+ }
13
+
14
+ export interface LinkMatcher {
15
+ type: LinkType
16
+ pattern: RegExp
17
+ getLinkUrl?: (text: string) => string
18
+ getLinkText?: (text: string) => string
19
+ baseUrl?: string
20
+ style?: StyleProp<TextStyle>
21
+ renderLink?: (text: string, url: string, index: number, type: LinkType) => React.ReactNode
22
+ onPress?: (url: string, type: LinkType) => void
23
+ }
24
+
25
+ interface LinkParserProps {
26
+ text: string
27
+ matchers?: LinkMatcher[]
28
+ email?: boolean
29
+ phone?: boolean
30
+ url?: boolean
31
+ hashtag?: boolean
32
+ mention?: boolean
33
+ hashtagUrl?: string
34
+ mentionUrl?: string
35
+ linkStyle?: StyleProp<TextStyle>
36
+ onPress?: (url: string, type: LinkType) => void
37
+ stripPrefix?: boolean
38
+ textStyle?: StyleProp<TextStyle>
39
+ TextComponent?: React.ComponentType<any>
40
+ }
41
+
42
+ const DEFAULT_MATCHERS: LinkMatcher[] = [
43
+ {
44
+ type: 'url',
45
+ pattern: /(?:(?:https?:\/\/)|(?:www\.))[^\s]+|[a-zA-Z0-9][a-zA-Z0-9-]+\.[a-zA-Z]{2,}(?:\/[^\s]*)?/gi,
46
+ getLinkUrl: (text: string) => {
47
+ if (!/^https?:\/\//i.test(text))
48
+ return `http://${text}`
49
+
50
+ return text
51
+ },
52
+ },
53
+ {
54
+ type: 'email',
55
+ pattern: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/gi,
56
+ getLinkUrl: (text: string) => `mailto:${text}`,
57
+ },
58
+ {
59
+ type: 'phone',
60
+ pattern: /(?:\+?\d{1,3}[\s.\-]?)?\(?\d{1,4}\)?[\s.\-]?\d{1,4}[\s.\-]?\d{1,9}/g,
61
+ getLinkUrl: (text: string) => {
62
+ const cleaned = text.replace(/[\s.()\-]/g, '')
63
+ return `tel:${cleaned}`
64
+ },
65
+ },
66
+ {
67
+ type: 'hashtag',
68
+ pattern: /#[\w]+/g,
69
+ getLinkUrl: (text: string) => text,
70
+ baseUrl: undefined,
71
+ },
72
+ {
73
+ type: 'mention',
74
+ pattern: /@[\w-]+/g,
75
+ getLinkUrl: (text: string) => text,
76
+ baseUrl: undefined,
77
+ },
78
+ ]
79
+
80
+ function parseLinks(text: string, matchers: LinkMatcher[]): ParsedLink[] {
81
+ const links: ParsedLink[] = []
82
+
83
+ matchers.forEach(matcher => {
84
+ const matches = text.matchAll(matcher.pattern)
85
+ for (const match of matches)
86
+ if (match.index !== undefined) {
87
+ const matchText = match[0]
88
+ const url = matcher.getLinkUrl
89
+ ? matcher.getLinkUrl(matchText)
90
+ : matchText
91
+ const linkText = matcher.getLinkText
92
+ ? matcher.getLinkText(matchText)
93
+ : matchText
94
+
95
+ links.push({
96
+ type: matcher.type,
97
+ text: linkText,
98
+ url,
99
+ index: match.index,
100
+ length: matchText.length,
101
+ })
102
+ }
103
+
104
+ })
105
+
106
+ // Sort by index to maintain order
107
+ return links.sort((a, b) => a.index - b.index)
108
+ }
109
+
110
+ function removeOverlaps(links: ParsedLink[]): ParsedLink[] {
111
+ const filtered: ParsedLink[] = []
112
+
113
+ for (const link of links) {
114
+ const hasOverlap = filtered.some(existing => {
115
+ const existingEnd = existing.index + existing.length
116
+ const linkEnd = link.index + link.length
117
+
118
+ return (
119
+ (link.index >= existing.index && link.index < existingEnd) ||
120
+ (linkEnd > existing.index && linkEnd <= existingEnd) ||
121
+ (link.index <= existing.index && linkEnd >= existingEnd)
122
+ )
123
+ })
124
+
125
+ if (!hasOverlap)
126
+ filtered.push(link)
127
+
128
+ }
129
+
130
+ return filtered
131
+ }
132
+
133
+ export function LinkParser({
134
+ text,
135
+ matchers: customMatchers,
136
+ email = true,
137
+ phone = true,
138
+ url = true,
139
+ hashtag = false,
140
+ mention = false,
141
+ hashtagUrl,
142
+ mentionUrl,
143
+ linkStyle,
144
+ onPress,
145
+ stripPrefix = true,
146
+ textStyle,
147
+ TextComponent = Text,
148
+ }: LinkParserProps): React.ReactElement {
149
+ const activeMatchers: LinkMatcher[] = []
150
+
151
+ // Add custom matchers first (they take precedence)
152
+ if (customMatchers)
153
+ activeMatchers.push(...customMatchers)
154
+
155
+
156
+ // Add default matchers based on flags
157
+ if (url && !customMatchers?.some(m => m.type === 'url'))
158
+ activeMatchers.push(DEFAULT_MATCHERS.find(m => m.type === 'url')!)
159
+
160
+ if (email && !customMatchers?.some(m => m.type === 'email'))
161
+ activeMatchers.push(DEFAULT_MATCHERS.find(m => m.type === 'email')!)
162
+
163
+ if (phone && !customMatchers?.some(m => m.type === 'phone'))
164
+ activeMatchers.push(DEFAULT_MATCHERS.find(m => m.type === 'phone')!)
165
+
166
+ if (hashtag && !customMatchers?.some(m => m.type === 'hashtag')) {
167
+ const hashtagMatcher = { ...DEFAULT_MATCHERS.find(m => m.type === 'hashtag')! }
168
+ if (hashtagUrl) {
169
+ hashtagMatcher.baseUrl = hashtagUrl
170
+ const baseUrl = hashtagUrl.endsWith('/') ? hashtagUrl : `${hashtagUrl}/`
171
+ hashtagMatcher.getLinkUrl = (text: string) => `${baseUrl}${text.substring(1)}`
172
+ }
173
+ activeMatchers.push(hashtagMatcher)
174
+ }
175
+
176
+ if (mention && !customMatchers?.some(m => m.type === 'mention')) {
177
+ const mentionMatcher = { ...DEFAULT_MATCHERS.find(m => m.type === 'mention')! }
178
+ if (mentionUrl) {
179
+ mentionMatcher.baseUrl = mentionUrl
180
+ const baseUrl = mentionUrl.endsWith('/') ? mentionUrl : `${mentionUrl}/`
181
+ mentionMatcher.getLinkUrl = (text: string) => `${baseUrl}${text.substring(1)}`
182
+ }
183
+ activeMatchers.push(mentionMatcher)
184
+ }
185
+
186
+
187
+ const links = removeOverlaps(parseLinks(text, activeMatchers))
188
+
189
+ if (links.length === 0)
190
+ return <TextComponent style={textStyle}>{text}</TextComponent>
191
+
192
+
193
+ const elements: React.ReactNode[] = []
194
+ let lastIndex = 0
195
+
196
+ links.forEach((link, index) => {
197
+ // Add text before link
198
+ if (link.index > lastIndex)
199
+ elements.push(
200
+ <TextComponent key={`text-${index}`} style={textStyle}>
201
+ {text.substring(lastIndex, link.index)}
202
+ </TextComponent>
203
+ )
204
+
205
+
206
+ // Find the matcher for this link
207
+ const matcher = activeMatchers.find(m => m.type === link.type)
208
+
209
+ // Handle link rendering
210
+ if (matcher?.renderLink) {
211
+ elements.push(matcher.renderLink(link.text, link.url, index, link.type))
212
+ } else {
213
+ const handlePress = () => {
214
+ if (matcher?.onPress)
215
+ matcher.onPress(link.url, link.type)
216
+ else if (onPress)
217
+ onPress(link.url, link.type)
218
+ else
219
+ // Default behavior
220
+ Linking.openURL(link.url).catch(err => {
221
+ console.warn('Failed to open URL:', err)
222
+ })
223
+
224
+ }
225
+
226
+ let displayText = link.text
227
+ if (stripPrefix && link.type === 'url')
228
+ displayText = displayText.replace(/^https?:\/\//i, '')
229
+
230
+
231
+ elements.push(
232
+ <TextComponent
233
+ key={`link-${index}`}
234
+ style={[linkStyle, matcher?.style]}
235
+ onPress={handlePress}
236
+ >
237
+ {displayText}
238
+ </TextComponent>
239
+ )
240
+ }
241
+
242
+ lastIndex = link.index + link.length
243
+ })
244
+
245
+ // Add remaining text
246
+ if (lastIndex < text.length)
247
+ elements.push(
248
+ <TextComponent key='text-end' style={textStyle}>
249
+ {text.substring(lastIndex)}
250
+ </TextComponent>
251
+ )
252
+
253
+
254
+ return <TextComponent style={textStyle}>{elements}</TextComponent>
255
+ }