@droppii-org/chat-mobile 0.2.32 → 0.2.33

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 (56) hide show
  1. package/lib/module/core/useChatListener.js +16 -1
  2. package/lib/module/core/useChatListener.js.map +1 -1
  3. package/lib/module/hooks/message/usePinMessage.js +133 -0
  4. package/lib/module/hooks/message/usePinMessage.js.map +1 -0
  5. package/lib/module/screens/chat-detail/ChatDetail.js +13 -1
  6. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  7. package/lib/module/screens/chat-detail/PinnedMessageBar.js +158 -0
  8. package/lib/module/screens/chat-detail/PinnedMessageBar.js.map +1 -0
  9. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +47 -10
  10. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  11. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxMenu.js +16 -0
  12. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxMenu.js.map +1 -1
  13. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js +15 -1
  14. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js.map +1 -1
  15. package/lib/module/screens/chat-detail/messageToolbox/messageToolbox.utils.js +16 -1
  16. package/lib/module/screens/chat-detail/messageToolbox/messageToolbox.utils.js.map +1 -1
  17. package/lib/module/store/pinnedMessage.js +34 -0
  18. package/lib/module/store/pinnedMessage.js.map +1 -0
  19. package/lib/module/translation/resources/i18n.js +12 -0
  20. package/lib/module/translation/resources/i18n.js.map +1 -1
  21. package/lib/module/types/chat.js.map +1 -1
  22. package/lib/module/utils/messageUtils.js +76 -0
  23. package/lib/module/utils/messageUtils.js.map +1 -1
  24. package/lib/typescript/src/core/useChatListener.d.ts.map +1 -1
  25. package/lib/typescript/src/hooks/message/usePinMessage.d.ts +9 -0
  26. package/lib/typescript/src/hooks/message/usePinMessage.d.ts.map +1 -0
  27. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  28. package/lib/typescript/src/screens/chat-detail/PinnedMessageBar.d.ts +8 -0
  29. package/lib/typescript/src/screens/chat-detail/PinnedMessageBar.d.ts.map +1 -0
  30. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  31. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxMenu.d.ts +5 -1
  32. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxMenu.d.ts.map +1 -1
  33. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts +2 -0
  34. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts.map +1 -1
  35. package/lib/typescript/src/screens/chat-detail/messageToolbox/messageToolbox.utils.d.ts +4 -0
  36. package/lib/typescript/src/screens/chat-detail/messageToolbox/messageToolbox.utils.d.ts.map +1 -1
  37. package/lib/typescript/src/store/pinnedMessage.d.ts +11 -0
  38. package/lib/typescript/src/store/pinnedMessage.d.ts.map +1 -0
  39. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  40. package/lib/typescript/src/types/chat.d.ts +4 -1
  41. package/lib/typescript/src/types/chat.d.ts.map +1 -1
  42. package/lib/typescript/src/utils/messageUtils.d.ts +8 -0
  43. package/lib/typescript/src/utils/messageUtils.d.ts.map +1 -1
  44. package/package.json +2 -2
  45. package/src/core/useChatListener.ts +26 -0
  46. package/src/hooks/message/usePinMessage.ts +153 -0
  47. package/src/screens/chat-detail/ChatDetail.tsx +12 -0
  48. package/src/screens/chat-detail/PinnedMessageBar.tsx +177 -0
  49. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +47 -10
  50. package/src/screens/chat-detail/messageToolbox/MessageToolboxMenu.tsx +24 -0
  51. package/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.tsx +142 -116
  52. package/src/screens/chat-detail/messageToolbox/messageToolbox.utils.ts +31 -0
  53. package/src/store/pinnedMessage.ts +38 -0
  54. package/src/translation/resources/i18n.ts +13 -0
  55. package/src/types/chat.ts +4 -1
  56. package/src/utils/messageUtils.ts +90 -0
@@ -0,0 +1,153 @@
1
+ import { useCallback, useEffect } from 'react';
2
+ import OpenIMSDK from '@droppii/openim-rn-client-sdk';
3
+ import type { MessageItem } from '@droppii/openim-rn-client-sdk';
4
+ import { useConversationStore } from '../../store/conversation';
5
+ import { useMessageStore } from '../../store/message';
6
+ import { usePinnedMessageStore } from '../../store/pinnedMessage';
7
+ import { useUserStore } from '../../store/user';
8
+ import { UIUtils } from '../../utils/UIUtils';
9
+ import { trans } from '../../translation';
10
+
11
+ const MAX_PINNED_MESSAGES = 5;
12
+
13
+ export const fetchPinnedMessages = async (conversationID: string) => {
14
+ const result = await OpenIMSDK.getPinnedMessageList({
15
+ conversationID,
16
+ offset: 0,
17
+ pageSize: MAX_PINNED_MESSAGES,
18
+ });
19
+
20
+ const previouslyPinnedIds = new Set(
21
+ usePinnedMessageStore.getState().messages.map((msg) => msg.clientMsgID)
22
+ );
23
+ const pinnedIds = new Set(result.messageList.map((msg) => msg.clientMsgID));
24
+
25
+ usePinnedMessageStore
26
+ .getState()
27
+ .setMessages(result.messageList, result.totalCount);
28
+
29
+ // Reconcile the per-message `isPinned` flag (used by the toolbox and the
30
+ // pinned badge) with the authoritative list from the server — messages
31
+ // pinned outside this session (before remount, or from another device)
32
+ // never get this flag set otherwise.
33
+ const { updateOneMessage } = useMessageStore.getState();
34
+ pinnedIds.forEach((clientMsgID) => {
35
+ updateOneMessage({ clientMsgID, isPinned: true });
36
+ });
37
+ previouslyPinnedIds.forEach((clientMsgID) => {
38
+ if (!pinnedIds.has(clientMsgID)) {
39
+ updateOneMessage({ clientMsgID, isPinned: false });
40
+ }
41
+ });
42
+ };
43
+
44
+ const showPinLimitReachedModal = () => {
45
+ UIUtils.alert.open({
46
+ title: trans('chat:pin_limit_title'),
47
+ message: trans('chat:pin_limit_desc'),
48
+ primaryIndex: 0,
49
+ buttons: [
50
+ {
51
+ label: trans('chat:pin_limit_primary'),
52
+ kind: 'primary',
53
+ onPress: () => {},
54
+ },
55
+ ],
56
+ });
57
+ };
58
+
59
+ export const usePinMessage = (conversationID?: string) => {
60
+ const pinnedMessages = usePinnedMessageStore((state) => state.messages);
61
+ const addMessage = usePinnedMessageStore((state) => state.addMessage);
62
+ const removeMessage = usePinnedMessageStore((state) => state.removeMessage);
63
+ const reset = usePinnedMessageStore((state) => state.reset);
64
+
65
+ useEffect(() => {
66
+ if (!conversationID) return;
67
+
68
+ fetchPinnedMessages(conversationID).catch((error) => {
69
+ console.error('fetchPinnedMessages', error);
70
+ });
71
+
72
+ return () => {
73
+ reset();
74
+ };
75
+ }, [conversationID, reset]);
76
+
77
+ const pinMessage = useCallback(
78
+ async (message: MessageItem) => {
79
+ if (!conversationID) return;
80
+
81
+ if (pinnedMessages.length >= MAX_PINNED_MESSAGES) {
82
+ showPinLimitReachedModal();
83
+ return;
84
+ }
85
+
86
+ try {
87
+ await OpenIMSDK.pinMsg(conversationID, message.clientMsgID);
88
+ addMessage(message);
89
+ useMessageStore.getState().updateOneMessage({
90
+ clientMsgID: message.clientMsgID,
91
+ isPinned: true,
92
+ pinnedByUserID: useUserStore.getState().user?.userID,
93
+ pinnedTime: Date.now(),
94
+ });
95
+ UIUtils.toast.open({
96
+ title: trans('chat:pin_message_success'),
97
+ theme: 'success',
98
+ });
99
+ } catch (error) {
100
+ console.error('pinMessage', error);
101
+ UIUtils.toast.open({
102
+ title: trans('chat:generic_error'),
103
+ theme: 'error',
104
+ });
105
+ }
106
+ },
107
+ [conversationID, pinnedMessages.length, addMessage]
108
+ );
109
+
110
+ const unpinMessage = useCallback(
111
+ async (message: MessageItem) => {
112
+ if (!conversationID) return;
113
+
114
+ try {
115
+ await OpenIMSDK.unpinMsg(conversationID, message.clientMsgID);
116
+ removeMessage(message.clientMsgID);
117
+ useMessageStore.getState().updateOneMessage({
118
+ clientMsgID: message.clientMsgID,
119
+ isPinned: false,
120
+ pinnedByUserID: undefined,
121
+ pinnedTime: undefined,
122
+ });
123
+ UIUtils.toast.open({
124
+ title: trans('chat:unpin_message_success'),
125
+ theme: 'success',
126
+ });
127
+ } catch (error) {
128
+ console.error('unpinMessage', error);
129
+ UIUtils.toast.open({
130
+ title: trans('chat:generic_error'),
131
+ theme: 'error',
132
+ });
133
+ }
134
+ },
135
+ [conversationID, removeMessage]
136
+ );
137
+
138
+ return {
139
+ pinnedMessages,
140
+ pinMessage,
141
+ unpinMessage,
142
+ };
143
+ };
144
+
145
+ export const syncPinnedMessagesOnEvent = (conversationID: string) => {
146
+ const currentConversationId =
147
+ useConversationStore.getState().currentConversationId;
148
+ if (conversationID !== currentConversationId) return;
149
+
150
+ fetchPinnedMessages(conversationID).catch((error) => {
151
+ console.error('syncPinnedMessagesOnEvent', error);
152
+ });
153
+ };
@@ -6,6 +6,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
6
6
  import { KContainer } from '@droppii/libs';
7
7
  import { useNavigation } from '@react-navigation/native';
8
8
  import { useChatMessages } from '../../hooks/useChatMessages';
9
+ import { usePinMessage } from '../../hooks/message/usePinMessage';
10
+ import { PinnedMessageBar } from './PinnedMessageBar';
9
11
  import {
10
12
  MediaViewerModal,
11
13
  type MediaViewerModalHandle,
@@ -76,6 +78,9 @@ const ChatDetail = memo(
76
78
  hasMoreEarlier,
77
79
  } = useChatMessages({ conversationId, enabled });
78
80
 
81
+ const { pinnedMessages, pinMessage, unpinMessage } =
82
+ usePinMessage(conversationId);
83
+
79
84
  useEffect(() => {
80
85
  if (conversationId) {
81
86
  useConversationStore.getState().setCurrentConversation(conversationId);
@@ -121,6 +126,11 @@ const ChatDetail = memo(
121
126
  onPressAvatar={onPressAvatar}
122
127
  />
123
128
 
129
+ <PinnedMessageBar
130
+ messages={pinnedMessages}
131
+ currentUserId={currentUserId}
132
+ />
133
+
124
134
  <KeyboardAvoidingView
125
135
  style={styles.keyboardAvoiding}
126
136
  behavior="padding"
@@ -150,6 +160,8 @@ const ChatDetail = memo(
150
160
  ref={messageToolboxRef}
151
161
  currentUserId={currentUserId}
152
162
  customMessageItemFactory={customMessageItemFactory}
163
+ onPinMessage={pinMessage}
164
+ onUnpinMessage={unpinMessage}
153
165
  />
154
166
  </KContainer.Page>
155
167
  );
@@ -0,0 +1,177 @@
1
+ import { memo, useMemo } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import {
4
+ KContainer,
5
+ KImage,
6
+ KLabel,
7
+ KColors,
8
+ KSpacingValue,
9
+ KBadge,
10
+ } from '@droppii/libs';
11
+ import { trans } from '../../translation';
12
+ import {
13
+ getMessagePreview,
14
+ getPinnedMessagePreview,
15
+ } from '../../utils/messageUtils';
16
+ import type { DMessageItem } from '../../types/chat';
17
+
18
+ interface PinnedMessageBarProps {
19
+ messages: DMessageItem[];
20
+ currentUserId?: string;
21
+ }
22
+
23
+ const PinnedMessageThumbnail = memo(
24
+ ({ message }: { message: DMessageItem }) => {
25
+ const preview = useMemo(() => getPinnedMessagePreview(message), [message]);
26
+
27
+ if (preview.mediaType === 'image' && preview.thumbnailUrl) {
28
+ return (
29
+ <KImage.Base
30
+ uri={preview.thumbnailUrl}
31
+ style={styles.thumbnail}
32
+ resizeMode="cover"
33
+ />
34
+ );
35
+ }
36
+
37
+ if (preview.mediaType === 'video') {
38
+ return (
39
+ <KContainer.View style={styles.thumbnail}>
40
+ {preview.thumbnailUrl && (
41
+ <KImage.Base
42
+ uri={preview.thumbnailUrl}
43
+ style={styles.thumbnail}
44
+ resizeMode="cover"
45
+ />
46
+ )}
47
+ <KContainer.View style={styles.videoOverlay} center>
48
+ <KImage.VectorIcons
49
+ name="play-circle"
50
+ provider="MaterialCommunityIcons"
51
+ size={16}
52
+ color={KColors.white}
53
+ />
54
+ </KContainer.View>
55
+ </KContainer.View>
56
+ );
57
+ }
58
+
59
+ if (preview.mediaType === 'file' && preview.fileIcon) {
60
+ return (
61
+ <KContainer.View marginL="0.75rem">
62
+ <KImage.Base
63
+ uri={preview.fileIcon}
64
+ style={styles.fileIcon}
65
+ resizeMode="contain"
66
+ />
67
+ </KContainer.View>
68
+ );
69
+ }
70
+
71
+ return null;
72
+ }
73
+ );
74
+
75
+ PinnedMessageThumbnail.displayName = 'PinnedMessageThumbnail';
76
+
77
+ export const PinnedMessageBar = memo(
78
+ ({ messages, currentUserId }: PinnedMessageBarProps) => {
79
+ if (messages.length === 0) {
80
+ return null;
81
+ }
82
+
83
+ const latest = messages[0]!;
84
+ const sender =
85
+ latest.sendID === currentUserId ? 'Bạn' : latest.senderNickname || '';
86
+ const remainingCount = messages.length - 1;
87
+ const { subtitleKey } = getPinnedMessagePreview(latest);
88
+
89
+ return (
90
+ <KContainer.View style={styles.container}>
91
+ <PinnedMessageThumbnail message={latest} />
92
+
93
+ <KContainer.View row alignItems flex paddingR="0.75rem">
94
+ <KContainer.View style={styles.content}>
95
+ <KLabel.Text typo="TextNmMedium" numberOfLines={1}>
96
+ {getMessagePreview(latest)}
97
+ </KLabel.Text>
98
+
99
+ <KContainer.View row alignItems paddingR="0.75rem">
100
+ <KImage.VectorIcons
101
+ name="thumbtack-b"
102
+ size={12}
103
+ color={KColors.primary.normal}
104
+ />
105
+ <KLabel.Text
106
+ typo="TextSmNormal"
107
+ color={KColors.gray.light}
108
+ numberOfLines={1}
109
+ marginL={'0.25rem'}
110
+ >
111
+ {trans(subtitleKey, { sender })}
112
+ </KLabel.Text>
113
+ </KContainer.View>
114
+ </KContainer.View>
115
+
116
+ {remainingCount > 0 && (
117
+ <KBadge.BaseV2
118
+ badge={`+${remainingCount}`}
119
+ skipConvertBadge
120
+ badgeSize={{ width: 26, height: 20 }}
121
+ wrapperLabelTextStyle={{ paddingHorizontal: 3 }}
122
+ />
123
+ )}
124
+ <KImage.VectorIcons
125
+ name="angle-down-o"
126
+ size={20}
127
+ color={KColors.gray.normal}
128
+ />
129
+ </KContainer.View>
130
+ </KContainer.View>
131
+ );
132
+ }
133
+ );
134
+
135
+ PinnedMessageBar.displayName = 'PinnedMessageBar';
136
+
137
+ const styles = StyleSheet.create({
138
+ container: {
139
+ flexDirection: 'row',
140
+ alignItems: 'center',
141
+ backgroundColor: KColors.palette.gray.w25,
142
+ },
143
+ content: {
144
+ flex: 1,
145
+ paddingRight: KSpacingValue['0.75rem'],
146
+ paddingLeft: KSpacingValue['0.75rem'],
147
+ paddingVertical: KSpacingValue['0.25rem'],
148
+ },
149
+ thumbnail: {
150
+ width: 51,
151
+ height: 51,
152
+ overflow: 'hidden',
153
+ backgroundColor: '#000',
154
+ },
155
+ videoOverlay: {
156
+ position: 'absolute',
157
+ top: 0,
158
+ left: 0,
159
+ right: 0,
160
+ bottom: 0,
161
+ backgroundColor: 'rgba(0, 0, 0, 0.25)',
162
+ },
163
+ fileIcon: {
164
+ width: 24,
165
+ height: 24,
166
+ },
167
+ badge: {
168
+ minWidth: 32,
169
+ height: 22,
170
+ paddingHorizontal: KSpacingValue['0.25rem'],
171
+ borderRadius: KSpacingValue['1rem'],
172
+ alignItems: 'center',
173
+ justifyContent: 'center',
174
+ backgroundColor: KColors.primary.normal,
175
+ marginRight: KSpacingValue['0.75rem'],
176
+ },
177
+ });
@@ -1,4 +1,5 @@
1
- import { KContainer } from '@droppii/libs';
1
+ import { KContainer, KImage, KColors } from '@droppii/libs';
2
+ import { StyleSheet } from 'react-native';
2
3
  import { memo, useMemo } from 'react';
3
4
  import type { ComponentType, PropsWithChildren, ReactNode } from 'react';
4
5
  import { MessageType } from '@droppii/openim-rn-client-sdk';
@@ -89,15 +90,27 @@ export const LegendChatMessage = memo(
89
90
  isOutgoing={isOutgoing}
90
91
  onRetry={onRetry}
91
92
  />
92
- <LongPressWrapper onLongPress={onLongPress}>
93
- <MessageComponent
94
- message={message}
95
- isOutgoing={isOutgoing}
96
- createdAtTime={createdAtTime}
97
- onMediaPress={hasMediaPress ? onMediaPress : undefined}
98
- onPressUrl={hasUrlPress ? onPressUrl : undefined}
99
- />
100
- </LongPressWrapper>
93
+ <KContainer.View style={styles.content}>
94
+ <LongPressWrapper onLongPress={onLongPress}>
95
+ <MessageComponent
96
+ message={message}
97
+ isOutgoing={isOutgoing}
98
+ createdAtTime={createdAtTime}
99
+ onMediaPress={hasMediaPress ? onMediaPress : undefined}
100
+ onPressUrl={hasUrlPress ? onPressUrl : undefined}
101
+ />
102
+ </LongPressWrapper>
103
+
104
+ {message?.isPinned && (
105
+ <KContainer.View style={styles.pinBadge}>
106
+ <KImage.VectorIcons
107
+ name="thumbtack-b"
108
+ size={12}
109
+ color={KColors.primary.normal}
110
+ />
111
+ </KContainer.View>
112
+ )}
113
+ </KContainer.View>
101
114
  </KContainer.View>
102
115
  );
103
116
  }
@@ -105,6 +118,30 @@ export const LegendChatMessage = memo(
105
118
 
106
119
  LegendChatMessage.displayName = 'LegendChatMessage';
107
120
 
121
+ const styles = StyleSheet.create({
122
+ content: {
123
+ position: 'relative',
124
+ },
125
+ pinBadge: {
126
+ position: 'absolute',
127
+ top: -8,
128
+ right: -8,
129
+ width: 24,
130
+ height: 20,
131
+ borderRadius: 100,
132
+ alignItems: 'center',
133
+ justifyContent: 'center',
134
+ backgroundColor: KColors.pageBackground,
135
+ shadowColor: KColors.black,
136
+ shadowOffset: { width: 0, height: 1 },
137
+ shadowOpacity: 0.15,
138
+ shadowRadius: 2,
139
+ elevation: 3,
140
+ borderWidth: 1,
141
+ borderColor: KColors.white,
142
+ },
143
+ });
144
+
108
145
  const LongPressWrapper = ({
109
146
  children,
110
147
  onLongPress,
@@ -7,10 +7,14 @@ import { MessageToolboxItem } from './MessageToolboxItem';
7
7
  interface MessageToolboxMenuProps {
8
8
  showCopyMessage?: boolean;
9
9
  showCopyMessageLink?: boolean;
10
+ showPinMessage?: boolean;
11
+ showUnpinMessage?: boolean;
10
12
  showRetryMessage?: boolean;
11
13
  showRevokeMessage?: boolean;
12
14
  onCopyMessage: () => void;
13
15
  onCopyMessageLink: () => void;
16
+ onPinMessage: () => void;
17
+ onUnpinMessage: () => void;
14
18
  onRetryMessage: () => void;
15
19
  onRevokeMessage: () => void;
16
20
  }
@@ -19,10 +23,14 @@ export const MessageToolboxMenu = memo(
19
23
  ({
20
24
  showCopyMessage,
21
25
  showCopyMessageLink,
26
+ showPinMessage,
27
+ showUnpinMessage,
22
28
  showRetryMessage,
23
29
  showRevokeMessage,
24
30
  onCopyMessage,
25
31
  onCopyMessageLink,
32
+ onPinMessage,
33
+ onUnpinMessage,
26
34
  onRetryMessage,
27
35
  onRevokeMessage,
28
36
  }: MessageToolboxMenuProps) => {
@@ -44,6 +52,22 @@ export const MessageToolboxMenu = memo(
44
52
  onPress={onCopyMessageLink}
45
53
  />
46
54
 
55
+ <MessageToolboxItem
56
+ icon="thumbtack-o"
57
+ color={KColors.gray.dark}
58
+ label={trans('chat:pin_message')}
59
+ visible={showPinMessage}
60
+ onPress={onPinMessage}
61
+ />
62
+
63
+ <MessageToolboxItem
64
+ icon="thumbtack-off-o"
65
+ color={KColors.gray.dark}
66
+ label={trans('chat:unpin_message')}
67
+ visible={showUnpinMessage}
68
+ onPress={onUnpinMessage}
69
+ />
70
+
47
71
  <MessageToolboxItem
48
72
  icon="arrow-reload-o"
49
73
  color={KColors.danger.normal}