@droppii-org/chat-mobile 0.2.81 → 0.2.83

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 (175) 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 +49 -92
  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/quotedMessage/index.js +79 -55
  12. package/lib/module/components/messages/quotedMessage/index.js.map +1 -1
  13. package/lib/module/components/messages/stickerMessage/index.js +38 -13
  14. package/lib/module/components/messages/stickerMessage/index.js.map +1 -1
  15. package/lib/module/components/messages/textMessage/index.js +126 -13
  16. package/lib/module/components/messages/textMessage/index.js.map +1 -1
  17. package/lib/module/components/messages/types.js.map +1 -1
  18. package/lib/module/core/useChatListener.js +1 -1
  19. package/lib/module/core/useChatListener.js.map +1 -1
  20. package/lib/module/hooks/message/useSendMessage.js +64 -3
  21. package/lib/module/hooks/message/useSendMessage.js.map +1 -1
  22. package/lib/module/hooks/query-keys.js +4 -0
  23. package/lib/module/hooks/query-keys.js.map +1 -1
  24. package/lib/module/hooks/useChatCompose.js +3 -2
  25. package/lib/module/hooks/useChatCompose.js.map +1 -1
  26. package/lib/module/screens/chat-detail/ChatComposer.js +170 -48
  27. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  28. package/lib/module/screens/chat-detail/ChatListLegend.js +16 -7
  29. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  30. package/lib/module/screens/chat-detail/components/ChatMessageInput.js +23 -2
  31. package/lib/module/screens/chat-detail/components/ChatMessageInput.js.map +1 -1
  32. package/lib/module/screens/chat-detail/components/MentionHighlightAdapter.js +51 -0
  33. package/lib/module/screens/chat-detail/components/MentionHighlightAdapter.js.map +1 -0
  34. package/lib/module/screens/chat-detail/components/MentionSuggestionItem.js +49 -0
  35. package/lib/module/screens/chat-detail/components/MentionSuggestionItem.js.map +1 -0
  36. package/lib/module/screens/chat-detail/components/MentionSuggestionList.js +114 -0
  37. package/lib/module/screens/chat-detail/components/MentionSuggestionList.js.map +1 -0
  38. package/lib/module/screens/chat-detail/hooks/useChatComposerState.js +8 -0
  39. package/lib/module/screens/chat-detail/hooks/useChatComposerState.js.map +1 -1
  40. package/lib/module/screens/chat-detail/hooks/useMentionComposer.js +251 -0
  41. package/lib/module/screens/chat-detail/hooks/useMentionComposer.js.map +1 -0
  42. package/lib/module/screens/chat-detail/legend/LegendChatDay.js +4 -9
  43. package/lib/module/screens/chat-detail/legend/LegendChatDay.js.map +1 -1
  44. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +14 -2
  45. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  46. package/lib/module/services/mentionSearch.js +49 -0
  47. package/lib/module/services/mentionSearch.js.map +1 -0
  48. package/lib/module/services/message.js +6 -1
  49. package/lib/module/services/message.js.map +1 -1
  50. package/lib/module/translation/resources/i18n.js +5 -0
  51. package/lib/module/translation/resources/i18n.js.map +1 -1
  52. package/lib/module/types/chat.js.map +1 -1
  53. package/lib/module/types/mention.js +2 -0
  54. package/lib/module/types/mention.js.map +1 -0
  55. package/lib/module/utils/legendListMessage.js +9 -1
  56. package/lib/module/utils/legendListMessage.js.map +1 -1
  57. package/lib/module/utils/mentionSerializer.js +113 -0
  58. package/lib/module/utils/mentionSerializer.js.map +1 -0
  59. package/lib/module/utils/mentions/index.js +7 -0
  60. package/lib/module/utils/mentions/index.js.map +1 -0
  61. package/lib/module/utils/mentions/mentionDetector.js +49 -0
  62. package/lib/module/utils/mentions/mentionDetector.js.map +1 -0
  63. package/lib/module/utils/mentions/mentionDiff.js +66 -0
  64. package/lib/module/utils/mentions/mentionDiff.js.map +1 -0
  65. package/lib/module/utils/mentions/mentionRange.js +188 -0
  66. package/lib/module/utils/mentions/mentionRange.js.map +1 -0
  67. package/lib/module/utils/mentions/mentionTokenize.js +50 -0
  68. package/lib/module/utils/mentions/mentionTokenize.js.map +1 -0
  69. package/lib/module/utils/messageUtils.js +6 -1
  70. package/lib/module/utils/messageUtils.js.map +1 -1
  71. package/lib/module/utils/url.js +33 -0
  72. package/lib/module/utils/url.js.map +1 -1
  73. package/lib/typescript/src/components/ThreadCard/MentionBadge.d.ts +7 -0
  74. package/lib/typescript/src/components/ThreadCard/MentionBadge.d.ts.map +1 -0
  75. package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
  76. package/lib/typescript/src/components/messages/linkMessage/LinkPreviewCard.d.ts +16 -0
  77. package/lib/typescript/src/components/messages/linkMessage/LinkPreviewCard.d.ts.map +1 -0
  78. package/lib/typescript/src/components/messages/linkMessage/index.d.ts +2 -2
  79. package/lib/typescript/src/components/messages/linkMessage/index.d.ts.map +1 -1
  80. package/lib/typescript/src/components/messages/quotedMessage/QuotedMessagePreviewStrip.d.ts +13 -0
  81. package/lib/typescript/src/components/messages/quotedMessage/QuotedMessagePreviewStrip.d.ts.map +1 -0
  82. package/lib/typescript/src/components/messages/quotedMessage/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/messages/stickerMessage/index.d.ts +2 -2
  84. package/lib/typescript/src/components/messages/stickerMessage/index.d.ts.map +1 -1
  85. package/lib/typescript/src/components/messages/textMessage/index.d.ts +4 -2
  86. package/lib/typescript/src/components/messages/textMessage/index.d.ts.map +1 -1
  87. package/lib/typescript/src/components/messages/types.d.ts +1 -0
  88. package/lib/typescript/src/components/messages/types.d.ts.map +1 -1
  89. package/lib/typescript/src/hooks/message/useSendMessage.d.ts +5 -2
  90. package/lib/typescript/src/hooks/message/useSendMessage.d.ts.map +1 -1
  91. package/lib/typescript/src/hooks/query-keys.d.ts +4 -0
  92. package/lib/typescript/src/hooks/query-keys.d.ts.map +1 -1
  93. package/lib/typescript/src/hooks/useChatCompose.d.ts +2 -1
  94. package/lib/typescript/src/hooks/useChatCompose.d.ts.map +1 -1
  95. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  96. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  97. package/lib/typescript/src/screens/chat-detail/components/ChatMessageInput.d.ts +10 -2
  98. package/lib/typescript/src/screens/chat-detail/components/ChatMessageInput.d.ts.map +1 -1
  99. package/lib/typescript/src/screens/chat-detail/components/MentionHighlightAdapter.d.ts +29 -0
  100. package/lib/typescript/src/screens/chat-detail/components/MentionHighlightAdapter.d.ts.map +1 -0
  101. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionItem.d.ts +8 -0
  102. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionItem.d.ts.map +1 -0
  103. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionList.d.ts +12 -0
  104. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionList.d.ts.map +1 -0
  105. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerState.d.ts +1 -1
  106. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerState.d.ts.map +1 -1
  107. package/lib/typescript/src/screens/chat-detail/hooks/useMentionComposer.d.ts +48 -0
  108. package/lib/typescript/src/screens/chat-detail/hooks/useMentionComposer.d.ts.map +1 -0
  109. package/lib/typescript/src/screens/chat-detail/legend/LegendChatDay.d.ts.map +1 -1
  110. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts +2 -1
  111. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  112. package/lib/typescript/src/services/mentionSearch.d.ts +15 -0
  113. package/lib/typescript/src/services/mentionSearch.d.ts.map +1 -0
  114. package/lib/typescript/src/services/message.d.ts +1 -1
  115. package/lib/typescript/src/services/message.d.ts.map +1 -1
  116. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  117. package/lib/typescript/src/types/chat.d.ts +23 -0
  118. package/lib/typescript/src/types/chat.d.ts.map +1 -1
  119. package/lib/typescript/src/types/mention.d.ts +65 -0
  120. package/lib/typescript/src/types/mention.d.ts.map +1 -0
  121. package/lib/typescript/src/utils/legendListMessage.d.ts +4 -3
  122. package/lib/typescript/src/utils/legendListMessage.d.ts.map +1 -1
  123. package/lib/typescript/src/utils/mentionSerializer.d.ts +78 -0
  124. package/lib/typescript/src/utils/mentionSerializer.d.ts.map +1 -0
  125. package/lib/typescript/src/utils/mentions/index.d.ts +5 -0
  126. package/lib/typescript/src/utils/mentions/index.d.ts.map +1 -0
  127. package/lib/typescript/src/utils/mentions/mentionDetector.d.ts +18 -0
  128. package/lib/typescript/src/utils/mentions/mentionDetector.d.ts.map +1 -0
  129. package/lib/typescript/src/utils/mentions/mentionDiff.d.ts +20 -0
  130. package/lib/typescript/src/utils/mentions/mentionDiff.d.ts.map +1 -0
  131. package/lib/typescript/src/utils/mentions/mentionRange.d.ts +86 -0
  132. package/lib/typescript/src/utils/mentions/mentionRange.d.ts.map +1 -0
  133. package/lib/typescript/src/utils/mentions/mentionTokenize.d.ts +29 -0
  134. package/lib/typescript/src/utils/mentions/mentionTokenize.d.ts.map +1 -0
  135. package/lib/typescript/src/utils/messageUtils.d.ts.map +1 -1
  136. package/lib/typescript/src/utils/url.d.ts +12 -0
  137. package/lib/typescript/src/utils/url.d.ts.map +1 -1
  138. package/package.json +1 -1
  139. package/src/components/ThreadCard/MentionBadge.tsx +28 -0
  140. package/src/components/ThreadCard/ThreadCard.tsx +3 -0
  141. package/src/components/messages/linkMessage/LinkPreviewCard.tsx +92 -0
  142. package/src/components/messages/linkMessage/index.tsx +64 -121
  143. package/src/components/messages/quotedMessage/QuotedMessagePreviewStrip.tsx +168 -0
  144. package/src/components/messages/quotedMessage/index.tsx +95 -64
  145. package/src/components/messages/stickerMessage/index.tsx +40 -11
  146. package/src/components/messages/textMessage/index.tsx +159 -17
  147. package/src/components/messages/types.ts +1 -0
  148. package/src/core/useChatListener.ts +1 -1
  149. package/src/hooks/message/useSendMessage.ts +60 -1
  150. package/src/hooks/query-keys.ts +6 -0
  151. package/src/hooks/useChatCompose.ts +115 -107
  152. package/src/screens/chat-detail/ChatComposer.tsx +201 -67
  153. package/src/screens/chat-detail/ChatListLegend.tsx +19 -4
  154. package/src/screens/chat-detail/components/ChatMessageInput.tsx +35 -2
  155. package/src/screens/chat-detail/components/MentionHighlightAdapter.tsx +57 -0
  156. package/src/screens/chat-detail/components/MentionSuggestionItem.tsx +53 -0
  157. package/src/screens/chat-detail/components/MentionSuggestionList.tsx +133 -0
  158. package/src/screens/chat-detail/hooks/useChatComposerState.ts +7 -1
  159. package/src/screens/chat-detail/hooks/useMentionComposer.ts +344 -0
  160. package/src/screens/chat-detail/legend/LegendChatDay.tsx +6 -9
  161. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +25 -2
  162. package/src/services/mentionSearch.ts +57 -0
  163. package/src/services/message.ts +9 -1
  164. package/src/translation/resources/i18n.ts +6 -0
  165. package/src/types/chat.ts +22 -0
  166. package/src/types/mention.ts +70 -0
  167. package/src/utils/legendListMessage.ts +13 -4
  168. package/src/utils/mentionSerializer.ts +155 -0
  169. package/src/utils/mentions/index.ts +16 -0
  170. package/src/utils/mentions/mentionDetector.ts +58 -0
  171. package/src/utils/mentions/mentionDiff.ts +95 -0
  172. package/src/utils/mentions/mentionRange.ts +207 -0
  173. package/src/utils/mentions/mentionTokenize.ts +53 -0
  174. package/src/utils/messageUtils.ts +6 -1
  175. package/src/utils/url.ts +30 -0
@@ -0,0 +1,133 @@
1
+ import { memo, useCallback } from 'react';
2
+ import { ActivityIndicator, StyleSheet } from 'react-native';
3
+ import { LegendList } from '@legendapp/list/react-native';
4
+ import { KContainer, KLabel, KColors, KSpacingValue } from '@droppii/libs';
5
+ import { MentionSuggestionItem } from './MentionSuggestionItem';
6
+ import { trans } from '../../../translation';
7
+ import type { MentionSuggestion } from '../../../types/mention';
8
+
9
+ // Figma (node 15066-319156): regular rows are 40px (single line, avatar
10
+ // height defines it), the "Tất cả" row is 45px (title + subtitle) — 40 is
11
+ // used as the shared estimate since most rows are the single-line kind.
12
+ const ROW_HEIGHT = 40;
13
+ const ROW_GAP = 8;
14
+ const MAX_VISIBLE_ROWS = 6;
15
+ const MAX_PANEL_HEIGHT =
16
+ ROW_HEIGHT * MAX_VISIBLE_ROWS + ROW_GAP * (MAX_VISIBLE_ROWS - 1);
17
+
18
+ interface MentionSuggestionListProps {
19
+ suggestions: MentionSuggestion[];
20
+ isSearching: boolean;
21
+ isLoadingMore?: boolean;
22
+ hasMore?: boolean;
23
+ onLoadMore?: () => void;
24
+ onSelect: (item: MentionSuggestion) => void;
25
+ }
26
+
27
+ export const MentionSuggestionList = memo(
28
+ ({
29
+ suggestions,
30
+ isSearching,
31
+ isLoadingMore,
32
+ hasMore,
33
+ onLoadMore,
34
+ onSelect,
35
+ }: MentionSuggestionListProps) => {
36
+ const renderItem = useCallback(
37
+ ({ item }: { item: MentionSuggestion }) => (
38
+ <MentionSuggestionItem item={item} onPress={onSelect} />
39
+ ),
40
+ [onSelect]
41
+ );
42
+
43
+ const keyExtractor = useCallback(
44
+ (item: MentionSuggestion) => item.userId,
45
+ []
46
+ );
47
+
48
+ const renderFooter = useCallback(() => {
49
+ if (!isLoadingMore) return null;
50
+ return (
51
+ <KContainer.View paddingV="0.5rem" center>
52
+ <ActivityIndicator color={KColors.palette.primary.w400} />
53
+ </KContainer.View>
54
+ );
55
+ }, [isLoadingMore]);
56
+
57
+ const renderSeparator = useCallback(
58
+ () => <KContainer.View style={styles.separator} />,
59
+ []
60
+ );
61
+
62
+ return (
63
+ <KContainer.View style={styles.shadowWrapper}>
64
+ <KContainer.View background={KColors.white} style={styles.container}>
65
+ <KLabel.Text
66
+ typo="TextNmBold"
67
+ color={KColors.palette.gray.w600}
68
+ paddingH="0.75rem"
69
+ paddingV="0.5rem"
70
+ >
71
+ {trans('group:tag_title')}
72
+ </KLabel.Text>
73
+
74
+ {suggestions.length === 0 && !isSearching ? (
75
+ <KLabel.Text
76
+ typo="TextSmNormal"
77
+ color={KColors.palette.gray.w600}
78
+ paddingH="0.75rem"
79
+ paddingB="0.75rem"
80
+ >
81
+ {trans('group:tag_no_member_found')}
82
+ </KLabel.Text>
83
+ ) : suggestions.length === 0 && isSearching ? (
84
+ <KContainer.View paddingV="0.75rem" center>
85
+ <ActivityIndicator color={KColors.palette.primary.w400} />
86
+ </KContainer.View>
87
+ ) : (
88
+ <KContainer.View style={styles.listWrapper} padding="0.5rem">
89
+ <LegendList
90
+ data={suggestions}
91
+ renderItem={renderItem}
92
+ keyExtractor={keyExtractor}
93
+ estimatedItemSize={ROW_HEIGHT}
94
+ keyboardShouldPersistTaps="handled"
95
+ onEndReached={hasMore ? onLoadMore : undefined}
96
+ onEndReachedThreshold={0.5}
97
+ ListFooterComponent={renderFooter}
98
+ ItemSeparatorComponent={renderSeparator}
99
+ />
100
+ </KContainer.View>
101
+ )}
102
+ </KContainer.View>
103
+ </KContainer.View>
104
+ );
105
+ }
106
+ );
107
+
108
+ MentionSuggestionList.displayName = 'MentionSuggestionList';
109
+
110
+ const styles = StyleSheet.create({
111
+ // Shadow lives on its own, non-clipping wrapper — a single view can't
112
+ // both clip to a rounded corner (overflow:'hidden', required below) and
113
+ // cast a shadow, since the clip cuts the shadow off too.
114
+ shadowWrapper: {
115
+ shadowColor: KColors.black,
116
+ shadowOffset: { width: 0, height: -8 },
117
+ shadowOpacity: 0.08,
118
+ shadowRadius: 16,
119
+ elevation: 4,
120
+ },
121
+ container: {
122
+ borderRadius: KSpacingValue['0.75rem'],
123
+ borderTopLeftRadius: KSpacingValue['0.75rem'],
124
+ borderTopRightRadius: KSpacingValue['0.75rem'],
125
+ overflow: 'hidden',
126
+ },
127
+ listWrapper: {
128
+ maxHeight: MAX_PANEL_HEIGHT,
129
+ },
130
+ separator: {
131
+ height: ROW_GAP,
132
+ },
133
+ });
@@ -1,6 +1,12 @@
1
1
  import { useState, useMemo, useRef } from 'react';
2
2
 
3
- export type ActivePanelType = 'attachment' | 'sticker' | null;
3
+ // 'mention' is used only for mutual-exclusion (closing attachment/sticker
4
+ // when the mention picker opens) — the mention picker itself renders as its
5
+ // own absolutely-positioned sibling in ChatComposer, not through this
6
+ // state's render slot, since it must float above the input while the
7
+ // keyboard stays open (attachment/sticker intentionally take over the
8
+ // keyboard area, which the mention picker must not do).
9
+ export type ActivePanelType = 'attachment' | 'sticker' | 'mention' | null;
4
10
 
5
11
  interface UseChatComposerStateReturn {
6
12
  activePanelType: ActivePanelType;
@@ -0,0 +1,344 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import type { TextInput, NativeSyntheticEvent } from 'react-native';
3
+ import type { TextInputSelectionChangeEventData } from 'react-native';
4
+ import { useInfiniteQuery } from '@tanstack/react-query';
5
+ import debounce from 'lodash/debounce';
6
+ import {
7
+ applyTextChange,
8
+ findActiveMention,
9
+ insertMention,
10
+ } from '../../../utils/mentions';
11
+ import { trans } from '../../../translation';
12
+ import { mentionQueryKeys } from '../../../hooks/query-keys';
13
+ import type {
14
+ ActiveMention,
15
+ Mention,
16
+ MentionSearchSource,
17
+ MentionSuggestion,
18
+ } from '../../../types/mention';
19
+
20
+ const SEARCH_DEBOUNCE_MS = 200;
21
+ // Matches MentionSuggestionList's MAX_VISIBLE_ROWS — one page fills exactly
22
+ // one screenful of the panel, so scrolling further triggers loadMore instead
23
+ // of over-fetching members the user hasn't scrolled to yet.
24
+ const SEARCH_LIMIT = 4;
25
+ const ALL_MEMBERS_USER_ID = 'ALL';
26
+
27
+ const allMembersSuggestion = (): MentionSuggestion => ({
28
+ userId: ALL_MEMBERS_USER_ID,
29
+ displayName: trans('group:tag_all'),
30
+ });
31
+
32
+ interface UseMentionComposerParams {
33
+ text: string;
34
+ setText: (text: string) => void;
35
+ searchSource: MentionSearchSource;
36
+ /** Namespaces the react-query cache per conversation — see the query
37
+ * itself for why this matters even though the hook is expected to remount
38
+ * per chat-detail screen. */
39
+ groupID?: string;
40
+ /** Mention only makes sense in group conversations (tagging a specific
41
+ * other member) — false for 1-1/bot/customer threads disables "@"
42
+ * detection entirely, not just the search results. Without this, typing
43
+ * "@" in a non-group chat would still open the picker (empty, since
44
+ * there's no real MentionSearchSource for it) instead of behaving as a
45
+ * plain character. Defaults to true so existing group-only callers don't
46
+ * need to pass it. */
47
+ enabled?: boolean;
48
+ inputRef: React.RefObject<TextInput | null>;
49
+ }
50
+
51
+ export interface UseMentionComposerReturn {
52
+ mentions: Mention[];
53
+ activeMention: ActiveMention | null;
54
+ suggestions: MentionSuggestion[];
55
+ isSearching: boolean;
56
+ isLoadingMore: boolean;
57
+ hasMore: boolean;
58
+ loadMore: () => void;
59
+ isPanelOpen: boolean;
60
+ handleChangeText: (text?: string) => void;
61
+ handleSelectionChange: (
62
+ event: NativeSyntheticEvent<TextInputSelectionChangeEventData>
63
+ ) => void;
64
+ selectSuggestion: (item: MentionSuggestion) => void;
65
+ closeMentionPanel: () => void;
66
+ clear: () => void;
67
+ }
68
+
69
+ /**
70
+ * Orchestrates the @mention composer: wires the pure Phase A1 logic
71
+ * (findActiveMention / applyTextChange / insertMention) and an injected
72
+ * MentionSearchSource into the live TextInput event loop.
73
+ *
74
+ * `text` stays owned by useChatCompose (the existing composer state) — this
75
+ * hook doesn't duplicate it, only mirrors it into a ref for synchronous
76
+ * access within its own event handlers (see textRef below).
77
+ */
78
+ export function useMentionComposer({
79
+ text,
80
+ setText,
81
+ searchSource,
82
+ groupID,
83
+ enabled = true,
84
+ inputRef,
85
+ }: UseMentionComposerParams): UseMentionComposerReturn {
86
+ const [mentions, setMentions] = useState<Mention[]>([]);
87
+ const [activeMention, setActiveMention] = useState<ActiveMention | null>(
88
+ null
89
+ );
90
+ // The keyword actually being searched for — distinct from
91
+ // activeMention.keyword, which updates on every keystroke. This only
92
+ // changes 200ms after typing pauses (see debouncedSetKeywordRef below),
93
+ // and IS the react-query key: typing further characters within the debounce
94
+ // window never touches the query at all, so the previous page's results
95
+ // stay on screen with zero flicker until the debounce actually fires.
96
+ // null means "no active search" (panel closed) — distinct from '' (an
97
+ // active search for the empty keyword, which also lists all members).
98
+ const [debouncedKeyword, setDebouncedKeyword] = useState<string | null>(null);
99
+
100
+ // Synchronous mirrors of the externally-owned `text` and internally-owned
101
+ // `mentions` state. React state updates aren't visible until the next
102
+ // render, but applyTextChange needs the *actual* latest committed values
103
+ // within the same tick a keystroke is processed — these refs are updated
104
+ // in lockstep with every setText/setMentions call below so they never lag.
105
+ const textRef = useRef(text);
106
+ const mentionsRef = useRef<Mention[]>([]);
107
+ const activeMentionRef = useRef<ActiveMention | null>(null);
108
+
109
+ const searchSourceRef = useRef(searchSource);
110
+ searchSourceRef.current = searchSource;
111
+
112
+ const enabledRef = useRef(enabled);
113
+ enabledRef.current = enabled;
114
+
115
+ useEffect(() => {
116
+ // External text changes not driven by this hook (e.g. clearing the
117
+ // composer after send) — keep the mirror and mention state in sync.
118
+ if (text !== textRef.current) {
119
+ textRef.current = text;
120
+ if (text === '') {
121
+ mentionsRef.current = [];
122
+ setMentions([]);
123
+ }
124
+ }
125
+ }, [text]);
126
+
127
+ const debouncedSetKeywordRef = useRef(
128
+ debounce(
129
+ (keyword: string) => setDebouncedKeyword(keyword),
130
+ SEARCH_DEBOUNCE_MS,
131
+ { leading: false, trailing: true }
132
+ )
133
+ );
134
+ useEffect(() => () => debouncedSetKeywordRef.current.cancel(), []);
135
+
136
+ const isActiveSearch = debouncedKeyword !== null;
137
+
138
+ const { data, isFetching, isFetchingNextPage, hasNextPage, fetchNextPage } =
139
+ useInfiniteQuery({
140
+ queryKey: mentionQueryKeys.search(groupID ?? '', debouncedKeyword ?? ''),
141
+ queryFn: ({ pageParam }) =>
142
+ searchSourceRef.current.search({
143
+ keyword: debouncedKeyword ?? '',
144
+ limit: SEARCH_LIMIT,
145
+ offset: pageParam,
146
+ }),
147
+ // Neither getGroupMemberList nor searchGroupMembers returns a total, so
148
+ // "is there a next page" is inferred from page size (see
149
+ // MentionSearchResult.hasMore's doc comment) instead of an exact count.
150
+ getNextPageParam: (lastPage, allPages) =>
151
+ lastPage.hasMore
152
+ ? allPages.reduce((sum, p) => sum + p.items.length, 0)
153
+ : undefined,
154
+ initialPageParam: 0,
155
+ enabled: isActiveSearch,
156
+ // Deliberately no maxPages cap — this is a scrolling picker, not a
157
+ // one-shot search; capping pages would drop already-loaded members as
158
+ // the user scrolls further, which is a worse experience than the memory
159
+ // cost of keeping them.
160
+ });
161
+
162
+ const includeAll = debouncedKeyword === '';
163
+ const suggestions = useMemo(() => {
164
+ const items = data?.pages.flatMap((page) => page.items) ?? [];
165
+ return includeAll ? [allMembersSuggestion(), ...items] : items;
166
+ }, [data, includeAll]);
167
+
168
+ const loadMore = useCallback(() => {
169
+ if (!isActiveSearch || !hasNextPage || isFetchingNextPage) return;
170
+ fetchNextPage();
171
+ }, [isActiveSearch, hasNextPage, isFetchingNextPage, fetchNextPage]);
172
+
173
+ const updateActiveMentionAndSearch = useCallback(
174
+ (currentText: string, cursor: number) => {
175
+ // Non-group conversation — "@" is a plain character, never a trigger.
176
+ // Checked here (not just left to the search source returning empty)
177
+ // so the picker UI never opens at all in this case.
178
+ const active = enabledRef.current
179
+ ? findActiveMention(currentText, cursor)
180
+ : null;
181
+ activeMentionRef.current = active;
182
+ setActiveMention(active);
183
+
184
+ if (!active) {
185
+ debouncedSetKeywordRef.current.cancel();
186
+ // Functional updater so an already-null value doesn't force a
187
+ // render on every ordinary keystroke (the common case).
188
+ setDebouncedKeyword((prev) => (prev === null ? prev : null));
189
+ return;
190
+ }
191
+
192
+ debouncedSetKeywordRef.current(active.keyword);
193
+ },
194
+ []
195
+ );
196
+
197
+ // Simplified from an earlier microtask-buffered design (see git history /
198
+ // plan doc for why that existed) after it caused a real, reproduced
199
+ // "Maximum update depth exceeded" loop in production testing. Root cause
200
+ // wasn't fully isolated without device logs, but the buffering added a
201
+ // second, redundant path (onSelectionChange re-running mention detection
202
+ // with native selection data that could be stale relative to the edit
203
+ // onChangeText had just processed) that could set state back and forth.
204
+ // applyTextChange already derives an authoritative cursor from the text
205
+ // diff itself — it never needed to wait for onSelectionChange for the
206
+ // edit case, so handling each event directly, synchronously, removes an
207
+ // entire dimension of timing uncertainty instead of trying to patch it.
208
+ const handleChangeText = useCallback(
209
+ (nextText?: string) => {
210
+ const nextTextRaw = nextText ?? '';
211
+ const previousText = textRef.current;
212
+ if (nextTextRaw === previousText) return;
213
+
214
+ const result = applyTextChange(
215
+ previousText,
216
+ mentionsRef.current,
217
+ nextTextRaw
218
+ );
219
+ textRef.current = result.text;
220
+ mentionsRef.current = result.mentions;
221
+ setText(result.text);
222
+ setMentions(result.mentions);
223
+
224
+ if (result.mentionRemoved) {
225
+ // The native buffer currently shows nextTextRaw, not our corrected
226
+ // result.text — force the cursor back to a position that's valid
227
+ // once React re-renders the controlled value.
228
+ requestAnimationFrame(() => {
229
+ inputRef.current?.setSelection(result.cursor, result.cursor);
230
+ });
231
+ }
232
+
233
+ updateActiveMentionAndSearch(result.text, result.cursor);
234
+ },
235
+ [inputRef, setText, updateActiveMentionAndSearch]
236
+ );
237
+
238
+ // Only meaningful for a pure cursor move with NO accompanying text change
239
+ // (tap elsewhere in the draft, arrow keys) — an edit's cursor is already
240
+ // handled authoritatively inside handleChangeText above. Deliberately
241
+ // does not re-run search/active-mention detection here to avoid the
242
+ // redundant double-handling that caused the loop this replaced.
243
+ const handleSelectionChange = useCallback(
244
+ (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => {
245
+ const selection = event.nativeEvent.selection;
246
+ if (selection.start !== selection.end) return;
247
+ if (selection.start === textRef.current.length) return; // typical post-edit echo, already handled
248
+
249
+ updateActiveMentionAndSearch(textRef.current, selection.start);
250
+ },
251
+ [updateActiveMentionAndSearch]
252
+ );
253
+
254
+ const selectSuggestion = useCallback(
255
+ (item: MentionSuggestion) => {
256
+ const active = activeMentionRef.current;
257
+ if (!active) return;
258
+
259
+ const result = insertMention(
260
+ textRef.current,
261
+ mentionsRef.current,
262
+ active,
263
+ {
264
+ userId: item.userId,
265
+ displayName: item.displayName,
266
+ }
267
+ );
268
+
269
+ textRef.current = result.text;
270
+ mentionsRef.current = result.mentions;
271
+ setText(result.text);
272
+ setMentions(result.mentions);
273
+ activeMentionRef.current = null;
274
+ setActiveMention(null);
275
+ debouncedSetKeywordRef.current.cancel();
276
+ setDebouncedKeyword(null);
277
+
278
+ requestAnimationFrame(() => {
279
+ inputRef.current?.setSelection(result.cursor, result.cursor);
280
+ });
281
+ },
282
+ [inputRef, setText]
283
+ );
284
+
285
+ // Called by ChatComposer when the user explicitly opens the
286
+ // attachment/sticker panel while a mention query is active — the mutual
287
+ // exclusion works both directions: opening mention closes attachment/
288
+ // sticker (ChatComposer's own handlers already do that reactively, see
289
+ // the effect wired there), and this closes mention when the user
290
+ // deliberately switches to a different panel.
291
+ const closeMentionPanel = useCallback(() => {
292
+ activeMentionRef.current = null;
293
+ setActiveMention(null);
294
+ debouncedSetKeywordRef.current.cancel();
295
+ setDebouncedKeyword(null);
296
+ }, []);
297
+
298
+ // Defensive: if `enabled` ever flips false while a mention query happens
299
+ // to be open (e.g. conversation type resolves after mount), close it
300
+ // immediately rather than leaving a stale picker for a thread that
301
+ // shouldn't have one at all.
302
+ useEffect(() => {
303
+ if (!enabled) closeMentionPanel();
304
+ // closeMentionPanel is stable ([] deps) — listing it here doesn't cause
305
+ // extra runs, it just keeps the deps array honest instead of suppressing
306
+ // the lint rule.
307
+ }, [enabled, closeMentionPanel]);
308
+
309
+ // Called explicitly by ChatComposer right when it clears the composer
310
+ // after send. The existing `useEffect` watching the `text` prop *would*
311
+ // eventually sync mentions to [] once `compose.setText('')` propagates
312
+ // down as a new `text` prop — but that's a render later, and in between
313
+ // there's a real transient bug: `value` is already '' while `mentions`
314
+ // still holds the old (now-stale) entries, and tokenize('', oldMentions)
315
+ // happily emits a mention segment anchored past the end of an empty
316
+ // string — a "ghost" mention with no surrounding text. Clearing
317
+ // synchronously here, in the same tick as setText(''), removes that
318
+ // window entirely instead of just shrinking it.
319
+ const clear = useCallback(() => {
320
+ textRef.current = '';
321
+ mentionsRef.current = [];
322
+ activeMentionRef.current = null;
323
+ setMentions([]);
324
+ setActiveMention(null);
325
+ debouncedSetKeywordRef.current.cancel();
326
+ setDebouncedKeyword(null);
327
+ }, []);
328
+
329
+ return {
330
+ mentions,
331
+ activeMention,
332
+ suggestions,
333
+ isSearching: isActiveSearch && isFetching && !isFetchingNextPage,
334
+ isLoadingMore: isFetchingNextPage,
335
+ hasMore: !!hasNextPage,
336
+ loadMore,
337
+ isPanelOpen: activeMention !== null,
338
+ handleChangeText,
339
+ handleSelectionChange,
340
+ selectSuggestion,
341
+ closeMentionPanel,
342
+ clear,
343
+ };
344
+ }
@@ -9,6 +9,8 @@ const formatTime = (date: Date) =>
9
9
  hour12: false,
10
10
  });
11
11
 
12
+ const pad2 = (value: number): string => String(value).padStart(2, '0');
13
+
12
14
  const formatDayLabel = (createdAt: number): string | null => {
13
15
  const date = new Date(createdAt);
14
16
  if (Number.isNaN(date.getTime())) return null;
@@ -30,18 +32,13 @@ const formatDayLabel = (createdAt: number): string | null => {
30
32
  return `${formatTime(date)} Hôm qua`;
31
33
  }
32
34
 
35
+ const dayMonth = `${pad2(date.getDate())}/${pad2(date.getMonth() + 1)}`;
36
+
33
37
  if (messageDay.getFullYear() === today.getFullYear()) {
34
- return `${formatTime(date)} ${date.toLocaleDateString('vi-VN', {
35
- day: 'numeric',
36
- month: 'long',
37
- })}`;
38
+ return `${formatTime(date)} ${dayMonth}`;
38
39
  }
39
40
 
40
- return `${formatTime(date)} ${date.toLocaleDateString('vi-VN', {
41
- day: 'numeric',
42
- month: 'long',
43
- year: 'numeric',
44
- })}`;
41
+ return `${formatTime(date)} ${dayMonth}/${date.getFullYear()}`;
45
42
  };
46
43
 
47
44
  interface LegendChatDayProps {
@@ -47,6 +47,7 @@ interface LegendChatMessageProps {
47
47
  onLongPress?: () => void;
48
48
  customMessageItemFactory?: (m: MessageItem) => ReactNode;
49
49
  isHighlighted?: boolean;
50
+ isTimeRevealed?: boolean;
50
51
  /** Sender name above the bubble — first message of an incoming cluster. */
51
52
  showName?: boolean;
52
53
  /** Sender avatar beside the bubble — last message of an incoming cluster. */
@@ -87,6 +88,14 @@ const MEDIA_TYPES: number[] = [
87
88
 
88
89
  const SYSTEM_MESSAGE_TYPES: number[] = GROUP_NOTIFICATION_TYPES;
89
90
 
91
+ // Tapping these types must keep opening the media/file as today — they never
92
+ // participate in tap-to-reveal-time, so no Tap gesture is attached at all for
93
+ // them (avoids racing their own internal touchables for the same tap).
94
+ const TIME_REVEAL_EXCLUDED_TYPES: number[] = [
95
+ ...MEDIA_TYPES,
96
+ MessageType.FileMessage,
97
+ ];
98
+
90
99
  export const LegendChatMessage = memo(
91
100
  ({
92
101
  message,
@@ -99,6 +108,7 @@ export const LegendChatMessage = memo(
99
108
  onLongPress,
100
109
  customMessageItemFactory,
101
110
  isHighlighted,
111
+ isTimeRevealed,
102
112
  showName = false,
103
113
  showAvatar = false,
104
114
  showSenderGutter = false,
@@ -137,8 +147,17 @@ export const LegendChatMessage = memo(
137
147
 
138
148
  const hasMediaPress =
139
149
  MEDIA_TYPES.includes(message?.contentType) && onMediaPress;
150
+ // AtTextMessage included: mention+URL combo attaches its link-preview
151
+ // card via ex.urlMetadata on an AtTextMessage (see TextMessage's doc
152
+ // comment) instead of a dedicated UrlTextMessage — the card still needs
153
+ // a tap handler.
140
154
  const hasUrlPress =
141
- onPressUrl && message?.contentType === MessageType.UrlTextMessage;
155
+ onPressUrl &&
156
+ (message?.contentType === MessageType.UrlTextMessage ||
157
+ message?.contentType === MessageType.AtTextMessage);
158
+ const canRevealTime = !TIME_REVEAL_EXCLUDED_TYPES.includes(
159
+ message?.contentType
160
+ );
142
161
 
143
162
  return (
144
163
  <KContainer.View style={messageStyles.wrapper} row alignItems>
@@ -170,7 +189,10 @@ export const LegendChatMessage = memo(
170
189
  {message.senderNickname}
171
190
  </KLabel.Text>
172
191
  )}
173
- <LongPressWrapper onPress={handlePress} onLongPress={handleLongPress}>
192
+ <LongPressWrapper
193
+ onPress={canRevealTime ? handlePress : undefined}
194
+ onLongPress={handleLongPress}
195
+ >
174
196
  <MessageComponent
175
197
  message={message}
176
198
  isOutgoing={isOutgoing}
@@ -178,6 +200,7 @@ export const LegendChatMessage = memo(
178
200
  onMediaPress={hasMediaPress ? onMediaPress : undefined}
179
201
  onPressUrl={hasUrlPress ? onPressUrl : undefined}
180
202
  isHighlighted={isHighlighted}
203
+ isTimeRevealed={canRevealTime && isTimeRevealed}
181
204
  groupPosition={groupPosition}
182
205
  />
183
206
  </LongPressWrapper>
@@ -0,0 +1,57 @@
1
+ import OpenIMSDK, {
2
+ GroupMemberFilter,
3
+ type GroupMemberItem,
4
+ } from '@droppii/openim-rn-client-sdk';
5
+ import type {
6
+ MentionSearchResult,
7
+ MentionSearchSource,
8
+ MentionSuggestion,
9
+ } from '../types/mention';
10
+
11
+ const toSuggestion = (member: GroupMemberItem): MentionSuggestion => ({
12
+ userId: member.userID,
13
+ displayName: member.nickname,
14
+ avatar: member.faceURL,
15
+ });
16
+
17
+ /**
18
+ * Concrete MentionSearchSource implementation for Part A's mention
19
+ * composer, wrapping the OpenIM SDK's group-member primitives — 0 call
20
+ * sites anywhere else in this repo before this feature. Empty keyword ->
21
+ * the full/initial member list (getGroupMemberList); non-empty keyword ->
22
+ * server-backed search (searchGroupMembers), purpose-built for this case.
23
+ *
24
+ * Neither SDK call returns a total count, so `hasMore` is inferred from
25
+ * page size (a full page *might* mean there's another one) — the same
26
+ * heuristic every offset/count-paginated list in this codebase already
27
+ * relies on, since OpenIM doesn't expose a cheaper alternative here.
28
+ */
29
+ export function createOpenIMMentionSearchSource(
30
+ groupID: string
31
+ ): MentionSearchSource {
32
+ return {
33
+ async search({ keyword, limit, offset }): Promise<MentionSearchResult> {
34
+ const members =
35
+ keyword === ''
36
+ ? await OpenIMSDK.getGroupMemberList({
37
+ groupID,
38
+ filter: GroupMemberFilter.All,
39
+ offset,
40
+ count: limit,
41
+ })
42
+ : await OpenIMSDK.searchGroupMembers({
43
+ groupID,
44
+ keywordList: [keyword],
45
+ isSearchUserID: false,
46
+ isSearchMemberNickname: true,
47
+ offset,
48
+ count: limit,
49
+ });
50
+
51
+ return {
52
+ items: members.map(toSuggestion),
53
+ hasMore: members.length === limit,
54
+ };
55
+ },
56
+ };
57
+ }
@@ -67,7 +67,15 @@ export namespace ChatMessageAPI {
67
67
  }
68
68
 
69
69
  export async function markConversationAsRead(conversationID: string) {
70
- return OpenIMSDK.markConversationMessageAsRead(conversationID);
70
+ // resetConversationGroupAtType clears the "@ mention" flag
71
+ // (ConversationItem.groupAtType) — the core SDK never resets it on its
72
+ // own (confirmed: markConversationMessageAsRead only zeroes
73
+ // unreadCount), so it has to be requested explicitly at the same
74
+ // "user is caught up on this conversation" moment as the read marker.
75
+ return Promise.all([
76
+ OpenIMSDK.markConversationMessageAsRead(conversationID),
77
+ OpenIMSDK.resetConversationGroupAtType(conversationID),
78
+ ]);
71
79
  }
72
80
 
73
81
  export async function getCurrentUserId() {
@@ -215,6 +215,12 @@ const i18nKey: Record<string, string> = {
215
215
  'group:dismiss_confirm_primary': 'Giải tán',
216
216
  'group:dismiss_confirm_secondary': 'Quay lại',
217
217
  'group:dismiss_toast_success': 'Đã giải tán nhóm',
218
+ 'group:tag_title': 'Nhắc đến',
219
+ 'group:tag_all': 'Tất cả',
220
+ 'group:tag_all_desc': 'Nhắc đến tất cả thành viên',
221
+ 'group:tag_no_member_found': 'Không tìm thấy thành viên nào',
222
+ 'group:tag_limit_exceeded':
223
+ 'Chỉ {{max}} người được nhắc đến đầu tiên sẽ nhận được thông báo',
218
224
  'group:access_blocked_title': 'Không thể xem cuộc trò chuyện này',
219
225
  'group:access_blocked_desc':
220
226
  'Bạn hiện không có quyền truy cập vào cuộc trò chuyện hoặc tin nhắn này.',