@droppii-org/chat-mobile 0.2.81 → 0.2.82

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.
Files changed (151) hide show
  1. package/lib/module/components/ThreadCard/MentionBadge.js +24 -0
  2. package/lib/module/components/ThreadCard/MentionBadge.js.map +1 -0
  3. package/lib/module/components/ThreadCard/ThreadCard.js +5 -1
  4. package/lib/module/components/ThreadCard/ThreadCard.js.map +1 -1
  5. package/lib/module/components/messages/linkMessage/LinkPreviewCard.js +67 -0
  6. package/lib/module/components/messages/linkMessage/LinkPreviewCard.js.map +1 -0
  7. package/lib/module/components/messages/linkMessage/index.js +21 -68
  8. package/lib/module/components/messages/linkMessage/index.js.map +1 -1
  9. package/lib/module/components/messages/quotedMessage/QuotedMessagePreviewStrip.js +152 -0
  10. package/lib/module/components/messages/quotedMessage/QuotedMessagePreviewStrip.js.map +1 -0
  11. package/lib/module/components/messages/textMessage/index.js +102 -8
  12. package/lib/module/components/messages/textMessage/index.js.map +1 -1
  13. package/lib/module/core/useChatListener.js +1 -1
  14. package/lib/module/core/useChatListener.js.map +1 -1
  15. package/lib/module/hooks/message/useSendMessage.js +64 -3
  16. package/lib/module/hooks/message/useSendMessage.js.map +1 -1
  17. package/lib/module/hooks/query-keys.js +4 -0
  18. package/lib/module/hooks/query-keys.js.map +1 -1
  19. package/lib/module/hooks/useChatCompose.js +3 -2
  20. package/lib/module/hooks/useChatCompose.js.map +1 -1
  21. package/lib/module/screens/chat-detail/ChatComposer.js +170 -48
  22. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  23. package/lib/module/screens/chat-detail/ChatListLegend.js +1 -0
  24. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  25. package/lib/module/screens/chat-detail/components/ChatMessageInput.js +23 -2
  26. package/lib/module/screens/chat-detail/components/ChatMessageInput.js.map +1 -1
  27. package/lib/module/screens/chat-detail/components/MentionHighlightAdapter.js +51 -0
  28. package/lib/module/screens/chat-detail/components/MentionHighlightAdapter.js.map +1 -0
  29. package/lib/module/screens/chat-detail/components/MentionSuggestionItem.js +49 -0
  30. package/lib/module/screens/chat-detail/components/MentionSuggestionItem.js.map +1 -0
  31. package/lib/module/screens/chat-detail/components/MentionSuggestionList.js +114 -0
  32. package/lib/module/screens/chat-detail/components/MentionSuggestionList.js.map +1 -0
  33. package/lib/module/screens/chat-detail/hooks/useChatComposerState.js +8 -0
  34. package/lib/module/screens/chat-detail/hooks/useChatComposerState.js.map +1 -1
  35. package/lib/module/screens/chat-detail/hooks/useMentionComposer.js +251 -0
  36. package/lib/module/screens/chat-detail/hooks/useMentionComposer.js.map +1 -0
  37. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +5 -1
  38. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  39. package/lib/module/services/mentionSearch.js +49 -0
  40. package/lib/module/services/mentionSearch.js.map +1 -0
  41. package/lib/module/services/message.js +6 -1
  42. package/lib/module/services/message.js.map +1 -1
  43. package/lib/module/translation/resources/i18n.js +5 -0
  44. package/lib/module/translation/resources/i18n.js.map +1 -1
  45. package/lib/module/types/chat.js.map +1 -1
  46. package/lib/module/types/mention.js +2 -0
  47. package/lib/module/types/mention.js.map +1 -0
  48. package/lib/module/utils/mentionSerializer.js +113 -0
  49. package/lib/module/utils/mentionSerializer.js.map +1 -0
  50. package/lib/module/utils/mentions/index.js +7 -0
  51. package/lib/module/utils/mentions/index.js.map +1 -0
  52. package/lib/module/utils/mentions/mentionDetector.js +49 -0
  53. package/lib/module/utils/mentions/mentionDetector.js.map +1 -0
  54. package/lib/module/utils/mentions/mentionDiff.js +66 -0
  55. package/lib/module/utils/mentions/mentionDiff.js.map +1 -0
  56. package/lib/module/utils/mentions/mentionRange.js +188 -0
  57. package/lib/module/utils/mentions/mentionRange.js.map +1 -0
  58. package/lib/module/utils/mentions/mentionTokenize.js +50 -0
  59. package/lib/module/utils/mentions/mentionTokenize.js.map +1 -0
  60. package/lib/module/utils/messageUtils.js +6 -1
  61. package/lib/module/utils/messageUtils.js.map +1 -1
  62. package/lib/module/utils/url.js +33 -0
  63. package/lib/module/utils/url.js.map +1 -1
  64. package/lib/typescript/src/components/ThreadCard/MentionBadge.d.ts +7 -0
  65. package/lib/typescript/src/components/ThreadCard/MentionBadge.d.ts.map +1 -0
  66. package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
  67. package/lib/typescript/src/components/messages/linkMessage/LinkPreviewCard.d.ts +16 -0
  68. package/lib/typescript/src/components/messages/linkMessage/LinkPreviewCard.d.ts.map +1 -0
  69. package/lib/typescript/src/components/messages/linkMessage/index.d.ts.map +1 -1
  70. package/lib/typescript/src/components/messages/quotedMessage/QuotedMessagePreviewStrip.d.ts +13 -0
  71. package/lib/typescript/src/components/messages/quotedMessage/QuotedMessagePreviewStrip.d.ts.map +1 -0
  72. package/lib/typescript/src/components/messages/textMessage/index.d.ts +3 -1
  73. package/lib/typescript/src/components/messages/textMessage/index.d.ts.map +1 -1
  74. package/lib/typescript/src/hooks/message/useSendMessage.d.ts +5 -2
  75. package/lib/typescript/src/hooks/message/useSendMessage.d.ts.map +1 -1
  76. package/lib/typescript/src/hooks/query-keys.d.ts +4 -0
  77. package/lib/typescript/src/hooks/query-keys.d.ts.map +1 -1
  78. package/lib/typescript/src/hooks/useChatCompose.d.ts +2 -1
  79. package/lib/typescript/src/hooks/useChatCompose.d.ts.map +1 -1
  80. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  81. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  82. package/lib/typescript/src/screens/chat-detail/components/ChatMessageInput.d.ts +10 -2
  83. package/lib/typescript/src/screens/chat-detail/components/ChatMessageInput.d.ts.map +1 -1
  84. package/lib/typescript/src/screens/chat-detail/components/MentionHighlightAdapter.d.ts +29 -0
  85. package/lib/typescript/src/screens/chat-detail/components/MentionHighlightAdapter.d.ts.map +1 -0
  86. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionItem.d.ts +8 -0
  87. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionItem.d.ts.map +1 -0
  88. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionList.d.ts +12 -0
  89. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionList.d.ts.map +1 -0
  90. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerState.d.ts +1 -1
  91. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerState.d.ts.map +1 -1
  92. package/lib/typescript/src/screens/chat-detail/hooks/useMentionComposer.d.ts +48 -0
  93. package/lib/typescript/src/screens/chat-detail/hooks/useMentionComposer.d.ts.map +1 -0
  94. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  95. package/lib/typescript/src/services/mentionSearch.d.ts +15 -0
  96. package/lib/typescript/src/services/mentionSearch.d.ts.map +1 -0
  97. package/lib/typescript/src/services/message.d.ts +1 -1
  98. package/lib/typescript/src/services/message.d.ts.map +1 -1
  99. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  100. package/lib/typescript/src/types/chat.d.ts +23 -0
  101. package/lib/typescript/src/types/chat.d.ts.map +1 -1
  102. package/lib/typescript/src/types/mention.d.ts +65 -0
  103. package/lib/typescript/src/types/mention.d.ts.map +1 -0
  104. package/lib/typescript/src/utils/mentionSerializer.d.ts +78 -0
  105. package/lib/typescript/src/utils/mentionSerializer.d.ts.map +1 -0
  106. package/lib/typescript/src/utils/mentions/index.d.ts +5 -0
  107. package/lib/typescript/src/utils/mentions/index.d.ts.map +1 -0
  108. package/lib/typescript/src/utils/mentions/mentionDetector.d.ts +18 -0
  109. package/lib/typescript/src/utils/mentions/mentionDetector.d.ts.map +1 -0
  110. package/lib/typescript/src/utils/mentions/mentionDiff.d.ts +20 -0
  111. package/lib/typescript/src/utils/mentions/mentionDiff.d.ts.map +1 -0
  112. package/lib/typescript/src/utils/mentions/mentionRange.d.ts +86 -0
  113. package/lib/typescript/src/utils/mentions/mentionRange.d.ts.map +1 -0
  114. package/lib/typescript/src/utils/mentions/mentionTokenize.d.ts +29 -0
  115. package/lib/typescript/src/utils/mentions/mentionTokenize.d.ts.map +1 -0
  116. package/lib/typescript/src/utils/messageUtils.d.ts.map +1 -1
  117. package/lib/typescript/src/utils/url.d.ts +12 -0
  118. package/lib/typescript/src/utils/url.d.ts.map +1 -1
  119. package/package.json +1 -1
  120. package/src/components/ThreadCard/MentionBadge.tsx +28 -0
  121. package/src/components/ThreadCard/ThreadCard.tsx +3 -0
  122. package/src/components/messages/linkMessage/LinkPreviewCard.tsx +92 -0
  123. package/src/components/messages/linkMessage/index.tsx +25 -89
  124. package/src/components/messages/quotedMessage/QuotedMessagePreviewStrip.tsx +168 -0
  125. package/src/components/messages/textMessage/index.tsx +124 -4
  126. package/src/core/useChatListener.ts +1 -1
  127. package/src/hooks/message/useSendMessage.ts +60 -1
  128. package/src/hooks/query-keys.ts +6 -0
  129. package/src/hooks/useChatCompose.ts +115 -107
  130. package/src/screens/chat-detail/ChatComposer.tsx +201 -67
  131. package/src/screens/chat-detail/ChatListLegend.tsx +1 -0
  132. package/src/screens/chat-detail/components/ChatMessageInput.tsx +35 -2
  133. package/src/screens/chat-detail/components/MentionHighlightAdapter.tsx +57 -0
  134. package/src/screens/chat-detail/components/MentionSuggestionItem.tsx +53 -0
  135. package/src/screens/chat-detail/components/MentionSuggestionList.tsx +133 -0
  136. package/src/screens/chat-detail/hooks/useChatComposerState.ts +7 -1
  137. package/src/screens/chat-detail/hooks/useMentionComposer.ts +344 -0
  138. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +7 -1
  139. package/src/services/mentionSearch.ts +57 -0
  140. package/src/services/message.ts +9 -1
  141. package/src/translation/resources/i18n.ts +6 -0
  142. package/src/types/chat.ts +22 -0
  143. package/src/types/mention.ts +70 -0
  144. package/src/utils/mentionSerializer.ts +155 -0
  145. package/src/utils/mentions/index.ts +16 -0
  146. package/src/utils/mentions/mentionDetector.ts +58 -0
  147. package/src/utils/mentions/mentionDiff.ts +95 -0
  148. package/src/utils/mentions/mentionRange.ts +207 -0
  149. package/src/utils/mentions/mentionTokenize.ts +53 -0
  150. package/src/utils/messageUtils.ts +6 -1
  151. package/src/utils/url.ts +30 -0
@@ -1,16 +1,12 @@
1
1
  import { memo } from 'react';
2
2
  import { StyleSheet } from 'react-native';
3
- import {
4
- KContainer,
5
- KImage,
6
- KLabel,
7
- KColors,
8
- KSpacingValue,
9
- } from '@droppii/libs';
3
+ import { KContainer, KLabel, KColors, KSpacingValue } from '@droppii/libs';
10
4
  import type { BaseLegendMessageProps } from '../types';
11
5
  import { CHAT_BUBBLE_COLORS } from '../../../screens/chat-detail/constants';
12
6
  import { parseUrlMetadata } from '../../../utils/messageUtils';
7
+ import { splitTextByUrls } from '../../../utils/url';
13
8
  import { messageStyles } from '../styles';
9
+ import { LinkPreviewCard } from './LinkPreviewCard';
14
10
  import { getTailCornerStyle } from '../bubbleTail.utils';
15
11
  import { BubbleTailRow } from '../BubbleTailRow';
16
12
 
@@ -18,45 +14,26 @@ const renderTextWithLinks = (
18
14
  content: string,
19
15
  urls: string[],
20
16
  onPressUrl?: (url: string) => void
21
- ) => {
22
- if (!urls.length) {
23
- return <KLabel.Text typo="TextMdNormal">{content}</KLabel.Text>;
24
- }
25
-
26
- const parts: { text: string; isUrl: boolean }[] = [];
27
- let remaining = content;
28
-
29
- for (const url of urls) {
30
- const idx = remaining.indexOf(url);
31
- if (idx === -1) continue;
32
- if (idx > 0) parts.push({ text: remaining.slice(0, idx), isUrl: false });
33
- parts.push({ text: url, isUrl: true });
34
- remaining = remaining.slice(idx + url.length);
35
- }
36
-
37
- if (remaining) parts.push({ text: remaining, isUrl: false });
38
-
39
- return (
40
- <KLabel.Text typo="TextMdNormal">
41
- {parts.map((part, i) =>
42
- part.isUrl ? (
43
- <KLabel.Text
44
- key={i}
45
- onPress={() => onPressUrl?.(part.text)}
46
- color={KColors.primary.normal}
47
- typo="TextMdMedium"
48
- >
49
- {part.text}
50
- </KLabel.Text>
51
- ) : (
52
- <KLabel.Text typo="TextMdNormal" key={i}>
53
- {part.text}
54
- </KLabel.Text>
55
- )
56
- )}
57
- </KLabel.Text>
58
- );
59
- };
17
+ ) => (
18
+ <KLabel.Text typo="TextMdNormal">
19
+ {splitTextByUrls(content, urls).map((part, i) =>
20
+ part.isUrl ? (
21
+ <KLabel.Text
22
+ key={i}
23
+ onPress={() => onPressUrl?.(part.text)}
24
+ color={KColors.primary.normal}
25
+ typo="TextMdMedium"
26
+ >
27
+ {part.text}
28
+ </KLabel.Text>
29
+ ) : (
30
+ <KLabel.Text typo="TextMdNormal" key={i}>
31
+ {part.text}
32
+ </KLabel.Text>
33
+ )
34
+ )}
35
+ </KLabel.Text>
36
+ );
60
37
 
61
38
  export const LinkMessage = memo(
62
39
  ({
@@ -87,49 +64,7 @@ export const LinkMessage = memo(
87
64
  )}
88
65
 
89
66
  {!!metadata && (
90
- <KContainer.Touchable
91
- style={styles.card}
92
- onPress={() => metadata.url && onPressUrl?.(metadata.url)}
93
- >
94
- {!!metadata.image && (
95
- <KContainer.View style={styles.cardThumb}>
96
- <KImage.Base
97
- uri={metadata.image}
98
- style={StyleSheet.absoluteFill}
99
- resizeMode="cover"
100
- />
101
- </KContainer.View>
102
- )}
103
- <KContainer.View style={styles.cardBody}>
104
- {!!metadata.title && (
105
- <KLabel.Text
106
- typo="TextNmMedium"
107
- color={KColors.palette.gray.w900}
108
- numberOfLines={1}
109
- >
110
- {metadata.title}
111
- </KLabel.Text>
112
- )}
113
- {!!metadata.url && (
114
- <KLabel.Text
115
- typo="TextXsMedium"
116
- color={KColors.palette.primary.w400}
117
- numberOfLines={1}
118
- >
119
- {metadata.url}
120
- </KLabel.Text>
121
- )}
122
- {!!metadata.description && (
123
- <KLabel.Text
124
- typo="TextXsNormal"
125
- color={KColors.gray.normal}
126
- numberOfLines={2}
127
- >
128
- {metadata.description}
129
- </KLabel.Text>
130
- )}
131
- </KContainer.View>
132
- </KContainer.Touchable>
67
+ <LinkPreviewCard metadata={metadata} onPressUrl={onPressUrl} />
133
68
  )}
134
69
  </KContainer.View>
135
70
  </BubbleTailRow>
@@ -157,6 +92,7 @@ const styles = StyleSheet.create({
157
92
  textRow: {
158
93
  paddingHorizontal: KSpacingValue['0.25rem'],
159
94
  },
95
+ timeLabel: { gap: 4, marginTop: 2 },
160
96
  card: {
161
97
  borderRadius: KSpacingValue['0.75rem'],
162
98
  borderWidth: 1,
@@ -0,0 +1,168 @@
1
+ import { memo, useCallback, useMemo } from 'react';
2
+ import { DeviceEventEmitter, StyleSheet } from 'react-native';
3
+ import {
4
+ KColors,
5
+ KContainer,
6
+ KImage,
7
+ KLabel,
8
+ KRadiusValue,
9
+ KSpacingValue,
10
+ } from '@droppii/libs';
11
+ import { MessageType, type MessageItem } from '@droppii/openim-rn-client-sdk';
12
+ import {
13
+ getMessagePreviewParts,
14
+ getReplySenderName,
15
+ parseUrlMetadata,
16
+ } from '../../../utils/messageUtils';
17
+ import { getFileIcon, getMimeType } from '../../../utils/imageUtils';
18
+ import { useUserStore } from '../../../store';
19
+ import { useChatDetailContext } from '../../../context';
20
+ import { ChatEvent } from '../../../types/events';
21
+
22
+ interface QuotedMessagePreviewStripProps {
23
+ quotedMessage?: MessageItem;
24
+ }
25
+
26
+ /**
27
+ * The "replying to ..." strip — shared by QuotedMessage (message.quoteElem)
28
+ * and TextMessage (message.atTextElem?.quoteMessage, for the mention+quote
29
+ * combo). Extracted so both content types render the exact same preview
30
+ * instead of two copies drifting apart.
31
+ */
32
+ export const QuotedMessagePreviewStrip = memo(
33
+ ({ quotedMessage }: QuotedMessagePreviewStripProps) => {
34
+ const { conversation } = useChatDetailContext();
35
+ const currentUserId = useUserStore((s) => s.user?.userID);
36
+ const messagePreview = getMessagePreviewParts(quotedMessage);
37
+
38
+ const imageUrl = useMemo(() => {
39
+ switch (quotedMessage?.contentType) {
40
+ case MessageType.PictureMessage:
41
+ return quotedMessage?.pictureElem?.snapshotPicture?.url || '';
42
+ case MessageType.VideoMessage:
43
+ return quotedMessage?.videoElem?.snapshotUrl || '';
44
+ case MessageType.UrlTextMessage: {
45
+ const metadata = parseUrlMetadata(quotedMessage?.ex);
46
+ return metadata?.image || '';
47
+ }
48
+ default:
49
+ return '';
50
+ }
51
+ }, [quotedMessage]);
52
+
53
+ const iconSource = useMemo(() => {
54
+ if (quotedMessage?.contentType !== MessageType.FileMessage) return '';
55
+ const mimeType = getMimeType(quotedMessage?.fileElem?.fileName || '');
56
+ return getFileIcon(mimeType);
57
+ }, [quotedMessage]);
58
+
59
+ const handlePress = useCallback(() => {
60
+ DeviceEventEmitter.emit(ChatEvent.ScrollToMessage, {
61
+ clientMessageId: quotedMessage?.clientMsgID || '',
62
+ });
63
+ }, [quotedMessage]);
64
+
65
+ return (
66
+ <KContainer.Touchable
67
+ style={styles.quotedContainer}
68
+ onPress={handlePress}
69
+ >
70
+ <KContainer.View style={styles.dot} />
71
+ <KContainer.VisibleView visible={!!imageUrl}>
72
+ <KContainer.View style={styles.imageContainer}>
73
+ <KImage.Base uri={imageUrl} style={styles.imageContainer} />
74
+ <KContainer.VisibleView
75
+ visible={quotedMessage?.contentType === MessageType.VideoMessage}
76
+ >
77
+ <KContainer.View style={styles.playIconContainer}>
78
+ <KContainer.View style={styles.playIcon}>
79
+ <KImage.VectorIcons
80
+ name="play-b"
81
+ size={12}
82
+ color={KColors.white}
83
+ />
84
+ </KContainer.View>
85
+ </KContainer.View>
86
+ </KContainer.VisibleView>
87
+ </KContainer.View>
88
+ </KContainer.VisibleView>
89
+ <KContainer.VisibleView visible={!!iconSource}>
90
+ <KImage.Base
91
+ uri={iconSource}
92
+ style={styles.icon}
93
+ resizeMode="contain"
94
+ />
95
+ </KContainer.VisibleView>
96
+ <KContainer.View flexS={1} style={styles.content}>
97
+ <KLabel.Text typo="TextNmMedium" numberOfLines={1}>
98
+ {getReplySenderName(
99
+ quotedMessage,
100
+ currentUserId,
101
+ conversation,
102
+ true
103
+ )}
104
+ </KLabel.Text>
105
+ <KLabel.Text
106
+ typo="TextSmNormal"
107
+ color={KColors.gray.light}
108
+ numberOfLines={1}
109
+ {...messagePreview.textProps}
110
+ >
111
+ {messagePreview.text}
112
+ </KLabel.Text>
113
+ </KContainer.View>
114
+ </KContainer.Touchable>
115
+ );
116
+ }
117
+ );
118
+
119
+ QuotedMessagePreviewStrip.displayName = 'QuotedMessagePreviewStrip';
120
+
121
+ const styles = StyleSheet.create({
122
+ quotedContainer: {
123
+ backgroundColor: KColors.white,
124
+ flexDirection: 'row',
125
+ alignItems: 'center',
126
+ gap: KSpacingValue['0.5rem'],
127
+ borderTopRightRadius: KRadiusValue['3x'],
128
+ borderBottomRightRadius: KRadiusValue['3x'],
129
+ paddingRight: KSpacingValue['0.5rem'],
130
+ maxWidth: '100%',
131
+ },
132
+ dot: {
133
+ width: 3,
134
+ height: '100%',
135
+ backgroundColor: KColors.palette.grayCool.w200,
136
+ borderRadius: KRadiusValue.x,
137
+ marginRight: -2,
138
+ },
139
+ content: {
140
+ paddingHorizontal: KSpacingValue['0.25rem'],
141
+ paddingVertical: KSpacingValue['0.25rem'],
142
+ },
143
+ icon: {
144
+ width: 24,
145
+ height: 24,
146
+ },
147
+ imageContainer: {
148
+ width: 42,
149
+ height: 42,
150
+ borderRadius: KRadiusValue['2x'],
151
+ },
152
+ playIconContainer: {
153
+ position: 'absolute',
154
+ top: 0,
155
+ left: 0,
156
+ right: 0,
157
+ bottom: 0,
158
+ justifyContent: 'center',
159
+ alignItems: 'center',
160
+ },
161
+ playIcon: {
162
+ borderRadius: 8,
163
+ padding: KSpacingValue['0.25rem'],
164
+ backgroundColor: KColors.hexToRgba(KColors.black, 0.6),
165
+ justifyContent: 'center',
166
+ alignItems: 'center',
167
+ },
168
+ });
@@ -1,29 +1,116 @@
1
- import { memo } from 'react';
2
- import { KContainer, KLabel } from '@droppii/libs';
1
+ import { memo, useMemo } from 'react';
2
+ import { Text } from 'react-native';
3
+ import { MessageType } from '@droppii/openim-rn-client-sdk';
4
+ import { KContainer, KLabel, KColors } from '@droppii/libs';
3
5
  import { getMessageText } from '../../../utils/legendListMessage';
4
6
  import { CHAT_BUBBLE_COLORS } from '../../../screens/chat-detail/constants';
5
7
  import { messageStyles } from '../styles';
6
8
  import { getTailCornerStyle } from '../bubbleTail.utils';
7
9
  import { BubbleTailRow } from '../BubbleTailRow';
8
10
  import type { BaseLegendMessageProps } from '../types';
11
+ import { StyleSheet } from 'react-native';
12
+ import { tokenize } from '../../../utils/mentions';
13
+ import { deserializeMentionRanges } from '../../../utils/mentionSerializer';
14
+ import type { IMessageItemEx } from '../../../types/chat';
15
+ import { QuotedMessagePreviewStrip } from '../quotedMessage/QuotedMessagePreviewStrip';
16
+ import { LinkPreviewCard } from '../linkMessage/LinkPreviewCard';
17
+ import { extractUrls, splitTextByUrls } from '../../../utils/url';
18
+
19
+ const parseMessageEx = (ex?: string): IMessageItemEx | undefined => {
20
+ if (!ex) return undefined;
21
+ try {
22
+ return JSON.parse(ex) as IMessageItemEx;
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ };
27
+
9
28
  export const TextMessage = memo(
10
29
  ({
11
30
  message,
12
31
  isOutgoing,
13
32
  isHighlighted,
33
+ onPressUrl,
14
34
  groupPosition,
15
- }: BaseLegendMessageProps) => {
35
+ }: BaseLegendMessageProps & { onPressUrl?: (url: string) => void }) => {
16
36
  const messageText = getMessageText(message);
17
37
 
38
+ const isAtTextMessage = message.contentType === MessageType.AtTextMessage;
39
+
40
+ // Both the reply-preview strip and the link-preview card below piggyback
41
+ // on an AtTextMessage instead of getting their own content type — OpenIM
42
+ // content types are mutually exclusive (AtText vs Quote vs UrlText), so
43
+ // once mention picks AtText (to keep native tag/notify semantics — see
44
+ // useSendMessage.ts), quote rides along via the native
45
+ // AtTextElem.quoteMessage field and the URL card rides along via our own
46
+ // ex.urlMetadata channel (already attached unconditionally on send).
47
+ const quotedMessage = isAtTextMessage
48
+ ? message.atTextElem?.quoteMessage
49
+ : undefined;
50
+
51
+ const parsedEx = useMemo(
52
+ () => (isAtTextMessage ? parseMessageEx(message.ex) : undefined),
53
+ [isAtTextMessage, message.ex]
54
+ );
55
+ const urlMetadata = parsedEx?.urlMetadata;
56
+
57
+ const mentions = useMemo(() => {
58
+ if (!isAtTextMessage || !message.atTextElem) return [];
59
+ // Wrapped under `offsets` — matches the wire shape other Droppii
60
+ // clients (e.g. the web/CRM app) already send (see
61
+ // IMessageItemEx.mentions doc comment for how this was confirmed).
62
+ return deserializeMentionRanges(
63
+ message.atTextElem,
64
+ parsedEx?.mentions?.offsets
65
+ );
66
+ }, [isAtTextMessage, message.atTextElem, parsedEx]);
67
+
68
+ const segments = useMemo(
69
+ () => (mentions.length > 0 ? tokenize(messageText, mentions) : null),
70
+ [mentions, messageText]
71
+ );
72
+
73
+ // Scoped to the mention+URL combo only (`segments` implies mentions are
74
+ // present) — a plain TextMessage with no mention falls through to
75
+ // `messageText` unchanged below, same as before this feature existed.
76
+ // Widening this to auto-linkify every TextMessage is a separate,
77
+ // deliberate product decision, not a side effect of this fix.
78
+ const urls = useMemo(
79
+ () => (segments ? extractUrls(messageText) : []),
80
+ [segments, messageText]
81
+ );
82
+
18
83
  if (!messageText?.trim()) {
19
84
  return null;
20
85
  }
21
86
 
87
+ // A plain-text run (the space between two mentions) may itself contain
88
+ // a URL — split further so it gets the same tappable/colored treatment
89
+ // LinkMessage gives a pure URL message, instead of sitting there as
90
+ // unstyled black text next to a colored mention.
91
+ const renderTextRun = (text: string, keyPrefix: string) => {
92
+ if (!urls.length) return <Text key={keyPrefix}>{text}</Text>;
93
+ return splitTextByUrls(text, urls).map((part, i) =>
94
+ part.isUrl ? (
95
+ <Text
96
+ key={`${keyPrefix}-url-${i}`}
97
+ style={urlTextStyle}
98
+ onPress={() => onPressUrl?.(part.text)}
99
+ >
100
+ {part.text}
101
+ </Text>
102
+ ) : (
103
+ <Text key={`${keyPrefix}-text-${i}`}>{part.text}</Text>
104
+ )
105
+ );
106
+ };
107
+
22
108
  return (
23
109
  <BubbleTailRow isOutgoing={isOutgoing} groupPosition={groupPosition}>
24
110
  <KContainer.View
25
111
  style={[
26
112
  messageStyles.bubble,
113
+ styles.bubbleContent,
27
114
  isOutgoing
28
115
  ? messageStyles.bubbleSent
29
116
  : messageStyles.bubbleReceived,
@@ -31,9 +118,25 @@ export const TextMessage = memo(
31
118
  getTailCornerStyle(isOutgoing, groupPosition),
32
119
  ]}
33
120
  >
121
+ {quotedMessage && (
122
+ <QuotedMessagePreviewStrip quotedMessage={quotedMessage} />
123
+ )}
34
124
  <KLabel.Text typo="TextMdNormal" color={CHAT_BUBBLE_COLORS.text}>
35
- {messageText}
125
+ {segments
126
+ ? segments.map((segment, index) =>
127
+ segment.type === 'mention' ? (
128
+ <Text key={segment.mention.id} style={mentionTextStyle}>
129
+ {`@${segment.mention.displayName}`}
130
+ </Text>
131
+ ) : (
132
+ renderTextRun(segment.text, `seg-${index}`)
133
+ )
134
+ )
135
+ : messageText}
36
136
  </KLabel.Text>
137
+ {!!urlMetadata && (
138
+ <LinkPreviewCard metadata={urlMetadata} onPressUrl={onPressUrl} />
139
+ )}
37
140
  </KContainer.View>
38
141
  </BubbleTailRow>
39
142
  );
@@ -41,3 +144,20 @@ export const TextMessage = memo(
41
144
  );
42
145
 
43
146
  TextMessage.displayName = 'TextMessage';
147
+
148
+ const styles = StyleSheet.create({
149
+ bubbleContent: { gap: 4 },
150
+ timeLabel: { gap: 4, marginTop: 2 },
151
+ });
152
+
153
+ // Same highlight color as the composer (MentionHighlightAdapter) and the
154
+ // Figma screenshots. This is a read-only <Text>, not an editable
155
+ // TextInput, so none of the nested-Text-in-TextInput bug class applies —
156
+ // nesting styled Text here is unconditionally safe.
157
+ const mentionTextStyle = { color: KColors.palette.primary.w400 };
158
+
159
+ // Same color LinkMessage uses for a URL substring — a mention message's
160
+ // plain-text run can contain a URL too (mention+URL combo, see
161
+ // useSendMessage.ts's doc comment), and it should look tappable/colored
162
+ // the same way there as it does in a pure UrlTextMessage.
163
+ const urlTextStyle = { color: KColors.primary.normal };
@@ -54,7 +54,7 @@ export const useChatListener = () => {
54
54
 
55
55
  const onConversationChanged = useCallback(
56
56
  (conversations: ConversationItem[]) => {
57
- // console.log('[Listener] OnConversationChanged', conversations);
57
+ console.log('[Listener] OnConversationChanged', conversations);
58
58
 
59
59
  // return; // TEMPORARILY DISABLED: OnConversationChanged/OnNewConversation can
60
60
  const oldMap = useConversationStore.getState().map;
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
2
2
  import { useConversationStore } from '../../store/conversation';
3
3
  import { useUserStore } from '../../store/user';
4
4
  import type {
5
+ AtMsgParams,
5
6
  CustomMsgParams,
6
7
  MessageItem,
7
8
  OfflinePush,
@@ -35,6 +36,8 @@ import { stickerQueryKeys } from '../query-keys';
35
36
  import { getMessagePreview } from '../../utils/messageUtils';
36
37
  import { useCanSendMessage } from '../useGroupPermission';
37
38
  import { UIUtils } from '../../utils/UIUtils';
39
+ import type { Mention } from '../../types/mention';
40
+ import { serializeMentionsForSend } from '../../utils/mentionSerializer';
38
41
 
39
42
  const buildSendParams = (
40
43
  message: MessageItem,
@@ -98,12 +101,14 @@ export const useSendMessage = () => {
98
101
  flow,
99
102
  customMessage,
100
103
  sticker,
104
+ mentions,
101
105
  }: {
102
106
  plainText?: string;
103
107
  urlMetadata?: IUrlMetadata;
104
108
  flow?: IRunConversationFlowPayload;
105
109
  customMessage?: CustomMessagePayload;
106
110
  sticker?: StickerItem;
111
+ mentions?: Mention[];
107
112
  }) => {
108
113
  if (!isSendMessageAllowed) {
109
114
  UIUtils.toast.open({
@@ -124,9 +129,11 @@ export const useSendMessage = () => {
124
129
  if (!recvID && !groupID) return;
125
130
 
126
131
  const messageList: MessageItem[] = [];
132
+ let exMentions: IMessageItemEx['mentions'];
127
133
 
128
134
  const hasText = !!plainText && plainText.trim() !== '';
129
135
  const hasPreview = !!urlMetadata?.url;
136
+ const hasMentions = !!mentions && mentions.length > 0;
130
137
 
131
138
  //text msg
132
139
  if (hasText) {
@@ -134,12 +141,51 @@ export const useSendMessage = () => {
134
141
  const isUrlMessage = urls.length > 0 || hasPreview;
135
142
  let message: MessageItem | null = null;
136
143
 
137
- if (quotedMessage) {
144
+ // Mention always wins the content type over a URL preview: AtText
145
+ // and UrlText are mutually exclusive OpenIM content types (no field
146
+ // for both), but `ex.urlMetadata` is attached unconditionally below
147
+ // regardless of which branch builds `message` — so the URL card
148
+ // still rides along via `ex` on an AtTextMessage. TextMessage's
149
+ // render side reads it and shows the card next to the
150
+ // mention-highlighted text (see its doc comment). Other clients
151
+ // without that rendering just see plain "@Name ... https://..."
152
+ // text — mention intact, only the rich card is missing.
153
+ if (quotedMessage && hasMentions && groupID) {
154
+ // Native mention+quote: AtMsgParams.message embeds the quoted
155
+ // message into AtTextElem.quoteMessage server/core-side (verified
156
+ // down to openimsdk-core/internal/conversation_msg/
157
+ // create_message.go:76-101 — QuoteMessage: qs is a real field
158
+ // assignment, not just a bridge pass-through) — keeps the mention's
159
+ // native tag/notify semantics instead of falling back to a plain
160
+ // QuoteMessage, which has no atUserIDList at all.
161
+ const serialized = serializeMentionsForSend(mentions);
162
+ exMentions = { offsets: serialized.exMentions };
163
+ message = await createTextAtMessage({
164
+ text: plainText || '',
165
+ atUserIDList: serialized.atUserIDList,
166
+ atUsersInfo: serialized.atUsersInfo,
167
+ message: quotedMessage,
168
+ });
169
+ setQuotedMessage(undefined);
170
+ } else if (quotedMessage) {
138
171
  message = await createQuotedMessage({
139
172
  text: plainText || '',
140
173
  message: quotedMessage,
141
174
  });
142
175
  setQuotedMessage(undefined);
176
+ } else if (hasMentions && groupID) {
177
+ // "Tất cả" serializes to OpenIM's own AT_ALL_TAG sentinel, not an
178
+ // expanded member list — no group-member fetch needed here, see
179
+ // AT_ALL_TAG's doc comment in mentionSerializer.ts for why.
180
+ const serialized = serializeMentionsForSend(mentions);
181
+ // Wrapped under `offsets` to match the wire shape other Droppii
182
+ // clients already send (see IMessageItemEx.mentions doc comment).
183
+ exMentions = { offsets: serialized.exMentions };
184
+ message = await createTextAtMessage({
185
+ text: plainText || '',
186
+ atUserIDList: serialized.atUserIDList,
187
+ atUsersInfo: serialized.atUsersInfo,
188
+ });
143
189
  } else if (isUrlMessage) {
144
190
  message = await createUrlTextMessage(plainText || '', urls);
145
191
  } else if (hasText) {
@@ -178,6 +224,7 @@ export const useSendMessage = () => {
178
224
  urlMetadata,
179
225
  flow,
180
226
  stickerId: sticker?.id,
227
+ mentions: exMentions,
181
228
  };
182
229
 
183
230
  await dispatchSend(
@@ -330,6 +377,18 @@ export const createUrlTextMessage = async (text: string, urls: string[]) => {
330
377
  return urlTextMessage;
331
378
  };
332
379
 
380
+ export const createTextAtMessage = async (params: AtMsgParams) => {
381
+ let atMessage = await OpenIMSDK.createTextAtMessage(params, uuid.v4())
382
+ .then((data) => {
383
+ return data;
384
+ })
385
+ .catch(({ errCode, errMsg }) => {
386
+ console.error('createTextAtMessage', errCode, errMsg);
387
+ return null;
388
+ });
389
+ return atMessage;
390
+ };
391
+
333
392
  export const createCustomMessage = async (params: CustomMsgParams) => {
334
393
  let customMessage = await OpenIMSDK.createCustomMessage(params, uuid.v4())
335
394
  .then((data) => {
@@ -62,6 +62,12 @@ export const messageQueryKeys = {
62
62
  [...messageQueryKeys.all, 'search', query, conversationId] as const,
63
63
  };
64
64
 
65
+ export const mentionQueryKeys = {
66
+ all: ['mentionSearch'] as const,
67
+ search: (groupID: string, keyword: string) =>
68
+ [...mentionQueryKeys.all, groupID, keyword] as const,
69
+ };
70
+
65
71
  export const contactQueryKeys = {
66
72
  all: ['contacts'] as const,
67
73
  search: (query: string, type?: string) =>