@ethora/chat-component-rn 26.5.10 → 26.5.11

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 (111) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/README.md +29 -1
  3. package/lib/commonjs/components/MainComponents/ChatHeader.js +31 -16
  4. package/lib/commonjs/components/MainComponents/ChatHeader.js.map +1 -1
  5. package/lib/commonjs/components/MainComponents/ChatRoom.js +17 -3
  6. package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
  7. package/lib/commonjs/components/MainComponents/ChatWrapper.js +17 -14
  8. package/lib/commonjs/components/MainComponents/ChatWrapper.js.map +1 -1
  9. package/lib/commonjs/components/MainComponents/MediaMessage.js +12 -9
  10. package/lib/commonjs/components/MainComponents/MediaMessage.js.map +1 -1
  11. package/lib/commonjs/components/MessageBubble/InteractionsOverlay.js +119 -0
  12. package/lib/commonjs/components/MessageBubble/InteractionsOverlay.js.map +1 -0
  13. package/lib/commonjs/components/MessageBubble/Message.js +39 -3
  14. package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
  15. package/lib/commonjs/components/MessageBubble/MessageInteractions.js +64 -36
  16. package/lib/commonjs/components/MessageBubble/MessageInteractions.js.map +1 -1
  17. package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js +153 -62
  18. package/lib/commonjs/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
  19. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js +20 -5
  20. package/lib/commonjs/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  21. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js +5 -1
  22. package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  23. package/lib/commonjs/components/Modals/ModalHeaderComponent.js +10 -0
  24. package/lib/commonjs/components/Modals/ModalHeaderComponent.js.map +1 -1
  25. package/lib/commonjs/components/Modals/styledModalComponents.js +13 -4
  26. package/lib/commonjs/components/Modals/styledModalComponents.js.map +1 -1
  27. package/lib/commonjs/components/styled/StyledComponents.js +6 -3
  28. package/lib/commonjs/components/styled/StyledComponents.js.map +1 -1
  29. package/lib/commonjs/components/styled/VideoMessage.js +44 -25
  30. package/lib/commonjs/components/styled/VideoMessage.js.map +1 -1
  31. package/lib/commonjs/helpers/mimeToExtension.js +56 -0
  32. package/lib/commonjs/helpers/mimeToExtension.js.map +1 -1
  33. package/lib/commonjs/hooks/useLogout.js +33 -8
  34. package/lib/commonjs/hooks/useLogout.js.map +1 -1
  35. package/lib/commonjs/networking/api-requests/rooms.api.js +29 -4
  36. package/lib/commonjs/networking/api-requests/rooms.api.js.map +1 -1
  37. package/lib/module/components/MainComponents/ChatHeader.js +31 -16
  38. package/lib/module/components/MainComponents/ChatHeader.js.map +1 -1
  39. package/lib/module/components/MainComponents/ChatRoom.js +18 -4
  40. package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
  41. package/lib/module/components/MainComponents/ChatWrapper.js +17 -14
  42. package/lib/module/components/MainComponents/ChatWrapper.js.map +1 -1
  43. package/lib/module/components/MainComponents/MediaMessage.js +13 -10
  44. package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
  45. package/lib/module/components/MessageBubble/InteractionsOverlay.js +112 -0
  46. package/lib/module/components/MessageBubble/InteractionsOverlay.js.map +1 -0
  47. package/lib/module/components/MessageBubble/Message.js +40 -4
  48. package/lib/module/components/MessageBubble/Message.js.map +1 -1
  49. package/lib/module/components/MessageBubble/MessageInteractions.js +66 -38
  50. package/lib/module/components/MessageBubble/MessageInteractions.js.map +1 -1
  51. package/lib/module/components/Modals/AttachSheet/AttachSheet.js +155 -64
  52. package/lib/module/components/Modals/AttachSheet/AttachSheet.js.map +1 -1
  53. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js +20 -5
  54. package/lib/module/components/Modals/ChatProfileModal/ChatProfileModal.js.map +1 -1
  55. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js +6 -2
  56. package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
  57. package/lib/module/components/Modals/ModalHeaderComponent.js +10 -0
  58. package/lib/module/components/Modals/ModalHeaderComponent.js.map +1 -1
  59. package/lib/module/components/Modals/styledModalComponents.js +13 -4
  60. package/lib/module/components/Modals/styledModalComponents.js.map +1 -1
  61. package/lib/module/components/styled/StyledComponents.js +6 -3
  62. package/lib/module/components/styled/StyledComponents.js.map +1 -1
  63. package/lib/module/components/styled/VideoMessage.js +45 -26
  64. package/lib/module/components/styled/VideoMessage.js.map +1 -1
  65. package/lib/module/helpers/mimeToExtension.js +55 -0
  66. package/lib/module/helpers/mimeToExtension.js.map +1 -1
  67. package/lib/module/hooks/useLogout.js +33 -8
  68. package/lib/module/hooks/useLogout.js.map +1 -1
  69. package/lib/module/networking/api-requests/rooms.api.js +29 -4
  70. package/lib/module/networking/api-requests/rooms.api.js.map +1 -1
  71. package/lib/typescript/components/MainComponents/ChatHeader.d.ts.map +1 -1
  72. package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
  73. package/lib/typescript/components/MainComponents/ChatWrapper.d.ts.map +1 -1
  74. package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
  75. package/lib/typescript/components/MessageBubble/InteractionsOverlay.d.ts +13 -0
  76. package/lib/typescript/components/MessageBubble/InteractionsOverlay.d.ts.map +1 -0
  77. package/lib/typescript/components/MessageBubble/Message.d.ts.map +1 -1
  78. package/lib/typescript/components/MessageBubble/MessageInteractions.d.ts.map +1 -1
  79. package/lib/typescript/components/Modals/AttachSheet/AttachSheet.d.ts.map +1 -1
  80. package/lib/typescript/components/Modals/ChatProfileModal/ChatProfileModal.d.ts.map +1 -1
  81. package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts.map +1 -1
  82. package/lib/typescript/components/Modals/ModalHeaderComponent.d.ts.map +1 -1
  83. package/lib/typescript/components/Modals/styledModalComponents.d.ts.map +1 -1
  84. package/lib/typescript/components/styled/StyledComponents.d.ts.map +1 -1
  85. package/lib/typescript/components/styled/VideoMessage.d.ts.map +1 -1
  86. package/lib/typescript/helpers/mimeToExtension.d.ts +8 -0
  87. package/lib/typescript/helpers/mimeToExtension.d.ts.map +1 -1
  88. package/lib/typescript/hooks/useLogout.d.ts +23 -1
  89. package/lib/typescript/hooks/useLogout.d.ts.map +1 -1
  90. package/lib/typescript/networking/api-requests/rooms.api.d.ts.map +1 -1
  91. package/lib/typescript/types/types.d.ts +16 -0
  92. package/lib/typescript/types/types.d.ts.map +1 -1
  93. package/package.json +7 -4
  94. package/src/components/MainComponents/ChatHeader.tsx +46 -15
  95. package/src/components/MainComponents/ChatRoom.tsx +17 -3
  96. package/src/components/MainComponents/ChatWrapper.tsx +28 -21
  97. package/src/components/MainComponents/MediaMessage.tsx +11 -8
  98. package/src/components/MessageBubble/InteractionsOverlay.tsx +121 -0
  99. package/src/components/MessageBubble/Message.tsx +44 -3
  100. package/src/components/MessageBubble/MessageInteractions.tsx +67 -48
  101. package/src/components/Modals/AttachSheet/AttachSheet.tsx +144 -51
  102. package/src/components/Modals/ChatProfileModal/ChatProfileModal.tsx +25 -3
  103. package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +10 -2
  104. package/src/components/Modals/ModalHeaderComponent.tsx +8 -1
  105. package/src/components/Modals/styledModalComponents.tsx +13 -4
  106. package/src/components/styled/StyledComponents.ts +6 -3
  107. package/src/components/styled/VideoMessage.tsx +21 -5
  108. package/src/helpers/mimeToExtension.ts +64 -0
  109. package/src/hooks/useLogout.tsx +33 -10
  110. package/src/networking/api-requests/rooms.api.ts +31 -7
  111. package/src/types/types.ts +16 -0
@@ -6,6 +6,7 @@ import {
6
6
  findNodeHandle,
7
7
  UIManager,
8
8
  Text,
9
+ Keyboard,
9
10
  } from 'react-native';
10
11
  import { useDispatch, useSelector } from 'react-redux';
11
12
  import { RootState } from '../../roomStore';
@@ -241,8 +242,25 @@ const Message: React.FC<MessageProps> = ({ message, isUser, isReply }) => {
241
242
  // onLayout — more accurate than estimating it here, and it's what keeps
242
243
  // the menu adjacent to the message instead of floating far above (#13).
243
244
  const handleLongPress = () => {
244
- const target = bubbleRef.current ?? messageRef.current;
245
- if (target) {
245
+ const target = (bubbleRef.current ?? messageRef.current) as any;
246
+ if (target?.measureInWindow) {
247
+ // Use measureInWindow — the SAME API the overlay host uses to find
248
+ // its own window origin. Both are then in identical window
249
+ // coordinates, so MessageInteractions' host-local conversion
250
+ // (subtract origin) is exact. Mixing this with UIManager.measure
251
+ // (pageX/pageY) drifted on Android (status-bar offset) and pushed the
252
+ // "below" menu too far from the message.
253
+ target.measureInWindow(
254
+ (x: number, y: number, width: number, height: number) => {
255
+ setContextMenuPosition({
256
+ left: x,
257
+ right: x + width,
258
+ top: y,
259
+ bottom: y + height,
260
+ });
261
+ }
262
+ );
263
+ } else if (target) {
246
264
  const nodeHandle = findNodeHandle(target);
247
265
  if (nodeHandle) {
248
266
  UIManager.measure(nodeHandle, (_x, _y, width, height, pageX, pageY) => {
@@ -291,7 +309,19 @@ const Message: React.FC<MessageProps> = ({ message, isUser, isReply }) => {
291
309
  >
292
310
  {!isUser && (
293
311
  <CustomMessagePhotoContainer
294
- onPress={() => handleUserAvatarClick(message.user)}
312
+ // `disableProfilesInteractions` gates every entry point to the
313
+ // user-profile popup: this in-bubble avatar AND the chat-title
314
+ // press in ChatHeader. When disabled we still render the avatar
315
+ // visually but make it non-interactive (no press, no a11y
316
+ // affordance) — consumers who want it gone entirely can return
317
+ // null from a custom message component.
318
+ onPress={
319
+ config?.disableProfilesInteractions
320
+ ? undefined
321
+ : () => handleUserAvatarClick(message.user)
322
+ }
323
+ disabled={!!config?.disableProfilesInteractions}
324
+ accessible={!config?.disableProfilesInteractions}
295
325
  >
296
326
  {message.user?.profileImage ? (
297
327
  <CustomMessagePhoto source={{ uri: message.user.profileImage }} />
@@ -310,6 +340,17 @@ const Message: React.FC<MessageProps> = ({ message, isUser, isReply }) => {
310
340
  flex: 1,
311
341
  alignItems: isUser ? 'flex-end' : 'flex-start',
312
342
  }}
343
+ // A plain tap anywhere on a message bubble dismisses the
344
+ // keyboard. The FlatList's keyboardShouldPersistTaps="handled"
345
+ // already dismisses taps on the empty GAPS between bubbles, but
346
+ // the bubble Pressable itself is a touch responder, so taps that
347
+ // landed on a message were "handled" and the keyboard stayed —
348
+ // which is exactly the "closes only every other time" symptom
349
+ // (hit a gap → close, hit a bubble → stay). Explicitly dismissing
350
+ // here makes it reliable across the whole list. onPress does NOT
351
+ // fire when onLongPress fires, so opening MessageInteractions via
352
+ // long-press never triggers this dismiss.
353
+ onPress={() => Keyboard.dismiss()}
313
354
  // disableInteractions hides the long-press → context menu
314
355
  // (delete / edit / reply / react). Mirrors web's config gate.
315
356
  onLongPress={
@@ -1,4 +1,4 @@
1
- import React, { useMemo, useState } from 'react';
1
+ import React, { useEffect, useId, useMemo, useState } from 'react';
2
2
  import { Delimeter, MenuItem } from '../ContextMenu/ContextMenuComponents';
3
3
  import { useSelector } from 'react-redux';
4
4
  import { RootState } from '../../roomStore';
@@ -8,7 +8,6 @@ import {
8
8
  } from '../../helpers/constants/MESSAGE_INTERACTIONS';
9
9
  import { IMessage } from '../../types/types';
10
10
  import {
11
- Modal,
12
11
  Text,
13
12
  StyleSheet,
14
13
  View,
@@ -18,6 +17,7 @@ import {
18
17
  } from 'react-native';
19
18
  import * as Clipboard from 'expo-clipboard';
20
19
  import { useToast } from '../../context/ToastContext';
20
+ import { useInteractionsOverlay } from './InteractionsOverlay';
21
21
 
22
22
  interface MessageInteractionsProps {
23
23
  isReply?: boolean;
@@ -47,6 +47,8 @@ const MessageInteractions: React.FC<MessageInteractionsProps> = ({
47
47
  handleEditMessage,
48
48
  }) => {
49
49
  const { showToast } = useToast();
50
+ const { present, dismiss, originX, originY } = useInteractionsOverlay();
51
+ const overlayId = useId();
50
52
 
51
53
  const config = useSelector(
52
54
  (state: RootState) => state.chatSettingStore.config
@@ -130,54 +132,71 @@ const MessageInteractions: React.FC<MessageInteractionsProps> = ({
130
132
  return { top, left };
131
133
  }, [position, menuSize, config?.keyboardVerticalOffset]);
132
134
 
133
- if (config?.disableInteractions) {return null;}
134
-
135
- return (
136
- <Modal
137
- transparent
138
- animationType="fade"
139
- visible={true}
140
- onRequestClose={closeMenu}
141
- >
142
- {!message.isDeleted && (
143
- <View style={styles.overlayFill}>
144
- <Pressable style={StyleSheet.absoluteFill} onPress={closeMenu} />
145
- <View
146
- style={[styles.contextMenu, memoPosition]}
147
- onLayout={handleMenuLayout}
148
- >
149
- <MenuItem onPress={() => handleCopyMessage(message.body!)}>
150
- <Text style={styles.menuText}>{MESSAGE_INTERACTIONS.COPY}</Text>
151
- <MESSAGE_INTERACTIONS_ICONS.COPY />
152
- </MenuItem>
153
- {isUser && (
154
- <>
155
- {/* Edit only for non-media messages (upstream guard) +
156
- consistent label styling (menuText). */}
157
- {message?.isMediafile !== 'true' && (
158
- <>
159
- <Delimeter />
160
- <MenuItem onPress={handleEditMessage}>
161
- <Text style={styles.menuText}>
162
- {MESSAGE_INTERACTIONS.EDIT}
163
- </Text>
164
- <MESSAGE_INTERACTIONS_ICONS.EDIT />
165
- </MenuItem>
166
- </>
167
- )}
168
- <Delimeter />
169
- <MenuItem onPress={handleDeleteMessage}>
170
- <Text style={styles.menuText}>{MESSAGE_INTERACTIONS.DELETE}</Text>
171
- <MESSAGE_INTERACTIONS_ICONS.DELETE />
172
- </MenuItem>
173
- </>
174
- )}
175
- </View>
135
+ // Window coords (memoPosition) → host-local coords. The overlay host
136
+ // sits below the status bar / header, so subtract its measured origin.
137
+ const localPosition = useMemo(() => {
138
+ if (!memoPosition) {return undefined;}
139
+ return {
140
+ ...memoPosition,
141
+ top: (memoPosition as { top: number }).top - originY,
142
+ left: (memoPosition as { left: number }).left - originX,
143
+ };
144
+ }, [memoPosition, originX, originY]);
145
+
146
+ const content =
147
+ config?.disableInteractions || message.isDeleted ? null : (
148
+ <View style={styles.overlayFill}>
149
+ <Pressable style={StyleSheet.absoluteFill} onPress={closeMenu} />
150
+ <View
151
+ style={[styles.contextMenu, localPosition]}
152
+ onLayout={handleMenuLayout}
153
+ >
154
+ <MenuItem onPress={() => handleCopyMessage(message.body!)}>
155
+ <Text style={styles.menuText}>{MESSAGE_INTERACTIONS.COPY}</Text>
156
+ <MESSAGE_INTERACTIONS_ICONS.COPY />
157
+ </MenuItem>
158
+ {isUser && (
159
+ <>
160
+ {/* Edit only for non-media messages (upstream guard) +
161
+ consistent label styling (menuText). */}
162
+ {message?.isMediafile !== 'true' && (
163
+ <>
164
+ <Delimeter />
165
+ <MenuItem onPress={handleEditMessage}>
166
+ <Text style={styles.menuText}>
167
+ {MESSAGE_INTERACTIONS.EDIT}
168
+ </Text>
169
+ <MESSAGE_INTERACTIONS_ICONS.EDIT />
170
+ </MenuItem>
171
+ </>
172
+ )}
173
+ <Delimeter />
174
+ <MenuItem onPress={handleDeleteMessage}>
175
+ <Text style={styles.menuText}>{MESSAGE_INTERACTIONS.DELETE}</Text>
176
+ <MESSAGE_INTERACTIONS_ICONS.DELETE />
177
+ </MenuItem>
178
+ </>
179
+ )}
176
180
  </View>
177
- )}
181
+ </View>
182
+ );
178
183
 
179
- </Modal>
180
- );
184
+ // Render the menu in the in-tree overlay host instead of a React Native
185
+ // <Modal>. A Modal opens its own window on Android, which steals focus
186
+ // from the chat input and dismisses the keyboard when the menu opens;
187
+ // hosting it in the same view tree keeps the keyboard up. Re-run every
188
+ // render so menuSize/position updates refresh the hosted node.
189
+ // eslint-disable-next-line react-hooks/exhaustive-deps
190
+ useEffect(() => {
191
+ if (content) {
192
+ present(overlayId, content);
193
+ } else {
194
+ dismiss(overlayId);
195
+ }
196
+ });
197
+ useEffect(() => () => dismiss(overlayId), [dismiss, overlayId]);
198
+
199
+ return null;
181
200
  };
182
201
 
183
202
  export default MessageInteractions;
@@ -1,7 +1,9 @@
1
1
  /** @format */
2
2
 
3
- import React, { useRef } from 'react';
3
+ import React, { useEffect, useRef, useState } from 'react';
4
4
  import {
5
+ Animated,
6
+ Easing,
5
7
  Modal,
6
8
  Platform,
7
9
  StyleSheet,
@@ -20,6 +22,11 @@ interface AttachSheetProps {
20
22
  primaryColor?: string;
21
23
  }
22
24
 
25
+ // Starting translateY for the sheet. Larger than any realistic sheet
26
+ // height so it sits fully below the viewport before opening; the exact
27
+ // number doesn't matter visually because it animates to 0.
28
+ const SHEET_OFFSCREEN = 600;
29
+
23
30
  const AttachSheet: React.FC<AttachSheetProps> = ({
24
31
  visible,
25
32
  onClose,
@@ -28,22 +35,94 @@ const AttachSheet: React.FC<AttachSheetProps> = ({
28
35
  onDocument,
29
36
  primaryColor = '#0052CD',
30
37
  }) => {
38
+ // Pending handler captured on row tap — runs once the EXIT animation
39
+ // finishes so iOS doesn't try to present an image picker over a
40
+ // still-dismissing modal (was the original reason for the
41
+ // platform-split + Modal.onDismiss dance). The animation completion
42
+ // callback now drives this on both platforms.
31
43
  const pendingRef = useRef<(() => void) | null>(null);
32
44
 
33
- const runPending = () => {
34
- const fn = pendingRef.current;
35
- pendingRef.current = null;
36
- fn?.();
37
- };
45
+ // Local "modal stays mounted while animating out" state. The parent
46
+ // controls `visible`; we mirror it but delay unmount until the exit
47
+ // animation has played so the slide-down is actually seen.
48
+ const [mounted, setMounted] = useState(visible);
38
49
 
39
- const trigger = (handler: () => void) => () => {
40
- if (Platform.OS === 'ios') {
41
- pendingRef.current = handler;
42
- onClose();
43
- } else {
44
- onClose();
45
- setTimeout(handler, 150);
50
+ // Two separate Animated values — the whole point of the rewrite:
51
+ // • backdropOpacity: fades the dim layer in/out (no slide).
52
+ // • sheetTranslateY: slides ONLY the sheet up from below.
53
+ // RN Modal's built-in `animationType="slide"` slides the entire
54
+ // contents (backdrop included) so the dim shade entered from the
55
+ // bottom — what looked "awful". Splitting these gives the standard
56
+ // bottom-sheet feel.
57
+ const backdropOpacity = useRef(new Animated.Value(0)).current;
58
+ const sheetTranslateY = useRef(
59
+ new Animated.Value(SHEET_OFFSCREEN)
60
+ ).current;
61
+
62
+ useEffect(() => {
63
+ if (visible) {
64
+ setMounted(true);
65
+ // Reset to starting positions BEFORE the Modal becomes visible so
66
+ // the first animated frame is below-the-fold (not a flash of the
67
+ // settled sheet).
68
+ backdropOpacity.setValue(0);
69
+ sheetTranslateY.setValue(SHEET_OFFSCREEN);
70
+ // requestAnimationFrame lets the Modal commit its mount before the
71
+ // animation starts; without this the first frame can be skipped.
72
+ const raf = requestAnimationFrame(() => {
73
+ Animated.parallel([
74
+ Animated.timing(backdropOpacity, {
75
+ toValue: 1,
76
+ duration: 220,
77
+ easing: Easing.out(Easing.cubic),
78
+ useNativeDriver: true,
79
+ }),
80
+ Animated.spring(sheetTranslateY, {
81
+ toValue: 0,
82
+ // Tuned for a quick-but-not-bouncy entry. tension is the
83
+ // "stiffness" knob, friction the "damping" knob in RN's
84
+ // spring config.
85
+ tension: 90,
86
+ friction: 14,
87
+ useNativeDriver: true,
88
+ }),
89
+ ]).start();
90
+ });
91
+ return () => cancelAnimationFrame(raf);
46
92
  }
93
+ if (!mounted) {return;}
94
+ // Exit: fade the backdrop, slide the sheet down, then unmount and
95
+ // fire any pending picker handler.
96
+ Animated.parallel([
97
+ Animated.timing(backdropOpacity, {
98
+ toValue: 0,
99
+ duration: 180,
100
+ easing: Easing.in(Easing.cubic),
101
+ useNativeDriver: true,
102
+ }),
103
+ Animated.timing(sheetTranslateY, {
104
+ toValue: SHEET_OFFSCREEN,
105
+ duration: 220,
106
+ easing: Easing.in(Easing.cubic),
107
+ useNativeDriver: true,
108
+ }),
109
+ ]).start(({ finished }) => {
110
+ if (!finished) {return;} // interrupted (rapid re-open) → leave state alone
111
+ setMounted(false);
112
+ const fn = pendingRef.current;
113
+ pendingRef.current = null;
114
+ fn?.();
115
+ });
116
+ return undefined;
117
+ }, [visible]); // eslint-disable-line react-hooks/exhaustive-deps
118
+
119
+ const trigger = (handler: () => void) => () => {
120
+ // Capture the handler and ask parent to close. The exit-animation
121
+ // completion above fires `handler` AFTER the sheet has slid away,
122
+ // so iOS no longer tries to present a picker over a dismissing
123
+ // modal — and Android no longer needs the ad-hoc 150ms setTimeout.
124
+ pendingRef.current = handler;
125
+ onClose();
47
126
  };
48
127
 
49
128
  const rows: {
@@ -75,52 +154,66 @@ const AttachSheet: React.FC<AttachSheetProps> = ({
75
154
  return (
76
155
  <Modal
77
156
  transparent
78
- visible={visible}
79
- animationType="slide"
157
+ visible={mounted}
158
+ // We drive the animation ourselves — disable RN's built-in slide
159
+ // (which slid the backdrop too) so the fade + spring above are the
160
+ // only motion.
161
+ animationType="none"
80
162
  onRequestClose={onClose}
81
- onDismiss={runPending}
163
+ // Hardware-back / iOS gesture also routes through onClose, which
164
+ // flips `visible` and triggers our exit animation above.
82
165
  >
83
- <TouchableOpacity
84
- style={styles.backdrop}
85
- activeOpacity={1}
86
- onPress={onClose}
166
+ <Animated.View
167
+ style={[styles.backdrop, { opacity: backdropOpacity }]}
87
168
  >
88
- <TouchableOpacity activeOpacity={1} style={styles.sheet}>
89
- <View style={styles.grabber} />
90
- <Text style={styles.title}>Attach</Text>
91
- {rows.map(({ label, hint, Icon, handler }, idx) => (
92
- <TouchableOpacity
93
- key={label}
94
- activeOpacity={0.6}
95
- style={[
96
- styles.row,
97
- idx < rows.length - 1 && styles.rowDivider,
98
- ]}
99
- onPress={trigger(handler)}
100
- >
101
- <View
169
+ <TouchableOpacity
170
+ style={StyleSheet.absoluteFill}
171
+ activeOpacity={1}
172
+ onPress={onClose}
173
+ />
174
+ <Animated.View
175
+ style={[
176
+ styles.sheet,
177
+ { transform: [{ translateY: sheetTranslateY }] },
178
+ ]}
179
+ >
180
+ <TouchableOpacity activeOpacity={1}>
181
+ <View style={styles.grabber} />
182
+ <Text style={styles.title}>Attach</Text>
183
+ {rows.map(({ label, hint, Icon, handler }, idx) => (
184
+ <TouchableOpacity
185
+ key={label}
186
+ activeOpacity={0.6}
102
187
  style={[
103
- styles.iconBubble,
104
- { backgroundColor: primaryColor + '14' },
188
+ styles.row,
189
+ idx < rows.length - 1 && styles.rowDivider,
105
190
  ]}
191
+ onPress={trigger(handler)}
106
192
  >
107
- <Icon color={primaryColor} width={20} height={20} />
108
- </View>
109
- <View style={styles.rowText}>
110
- <Text style={styles.rowLabel}>{label}</Text>
111
- <Text style={styles.rowHint}>{hint}</Text>
112
- </View>
193
+ <View
194
+ style={[
195
+ styles.iconBubble,
196
+ { backgroundColor: primaryColor + '14' },
197
+ ]}
198
+ >
199
+ <Icon color={primaryColor} width={20} height={20} />
200
+ </View>
201
+ <View style={styles.rowText}>
202
+ <Text style={styles.rowLabel}>{label}</Text>
203
+ <Text style={styles.rowHint}>{hint}</Text>
204
+ </View>
205
+ </TouchableOpacity>
206
+ ))}
207
+ <TouchableOpacity
208
+ activeOpacity={0.6}
209
+ style={styles.cancelRow}
210
+ onPress={onClose}
211
+ >
212
+ <Text style={styles.cancelLabel}>Cancel</Text>
113
213
  </TouchableOpacity>
114
- ))}
115
- <TouchableOpacity
116
- activeOpacity={0.6}
117
- style={styles.cancelRow}
118
- onPress={onClose}
119
- >
120
- <Text style={styles.cancelLabel}>Cancel</Text>
121
214
  </TouchableOpacity>
122
- </TouchableOpacity>
123
- </TouchableOpacity>
215
+ </Animated.View>
216
+ </Animated.View>
124
217
  </Modal>
125
218
  );
126
219
  };
@@ -293,11 +293,22 @@ const ChatProfileModal: React.FC<ChatProfileModalProps> = ({
293
293
  <ProfileImagePlaceholder
294
294
  name={activeRoom.title || activeRoom.name}
295
295
  icon={activeRoom.icon}
296
+ // Two gates control whether the icon is editable:
297
+ // 1. role — only non-participants could edit before.
298
+ // 2. NEW: `disableChatInfo.disableIconEdit` — hard kill from
299
+ // config so a consumer can make the icon read-only for
300
+ // everyone (e.g. branded rooms, patient-facing apps).
301
+ // Both must allow it for the picker/remove affordances to show.
296
302
  upload={{
297
303
  onUpload,
298
- active: activeRoom?.role !== 'participant' ? true : false,
304
+ active:
305
+ !config?.disableChatInfo?.disableIconEdit &&
306
+ activeRoom?.role !== 'participant',
307
+ }}
308
+ remove={{
309
+ enabled: !config?.disableChatInfo?.disableIconEdit,
310
+ onRemoveClick,
299
311
  }}
300
- remove={{ enabled: true, onRemoveClick }}
301
312
  role={activeRoom?.role}
302
313
  size={128}
303
314
  />
@@ -377,7 +388,18 @@ const ChatProfileModal: React.FC<ChatProfileModalProps> = ({
377
388
  >
378
389
  <Pressable
379
390
  style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start', gap: 16 }}
380
- onPress={() => handleUserAvatarClick(user)}
391
+ // `disableChatInfo.disableMemberTap` blocks the
392
+ // tap entirely so the user-profile popup never
393
+ // opens. `disableMemberProfileActions` only hid
394
+ // the action buttons INSIDE that popup, leaving
395
+ // the popup itself to open on tap — this gate
396
+ // closes the door. Customer-reported #16.
397
+ onPress={
398
+ config?.disableChatInfo?.disableMemberTap
399
+ ? undefined
400
+ : () => handleUserAvatarClick(user)
401
+ }
402
+ disabled={!!config?.disableChatInfo?.disableMemberTap}
381
403
  >
382
404
  <ProfileImagePlaceholder
383
405
  name={`${user.firstName} ${user.lastName}`}
@@ -27,7 +27,7 @@ import { useToast } from '../../../context/ToastContext';
27
27
  import PdfViewer from './PdfView';
28
28
  import DocumentViewer from './DocumentViewer';
29
29
  import AudioMessage from '../../styled/AudioMessage';
30
- import { ensureFilenameHasExtension } from '../../../helpers/mimeToExtension';
30
+ import { ensureFilenameHasExtension, isLikelyAudio } from '../../../helpers/mimeToExtension';
31
31
 
32
32
  // MIME types Google's gview embed renders reliably. Everything else
33
33
  // falls through to the info-card so the user can still download.
@@ -240,7 +240,15 @@ const FilePreviewModal: React.FC<FilePreviewModalProps> = ({
240
240
  );
241
241
  case activeFile.mimetype.startsWith('video/'):
242
242
  return <ModalVideo uri={activeFile.fileURL} />;
243
- case activeFile.mimetype.startsWith('audio/'):
243
+ // Mirrors the MediaMessage heuristic: treat octet-stream voicemails
244
+ // with audio-shaped filenames / URLs as audio so the preview shows
245
+ // the player instead of an "Unsupported" card. Customer-reported
246
+ // #9 voicemail fix — see isLikelyAudio in mimeToExtension.ts.
247
+ case isLikelyAudio(
248
+ activeFile.mimetype,
249
+ activeFile.fileName,
250
+ activeFile.fileURL
251
+ ):
244
252
  return (
245
253
  <View
246
254
  style={{
@@ -7,6 +7,7 @@ import {
7
7
  import { BackIcon, MoreIcon, QrIcon } from '../../assets/icons';
8
8
  import Button from '../styled/Button';
9
9
  import { Text } from 'react-native';
10
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
10
11
 
11
12
  interface ModalHeaderComponentProps {
12
13
  handleCloseModal?: any;
@@ -21,8 +22,14 @@ const ModalHeaderComponent: React.FC<ModalHeaderComponentProps> = ({
21
22
  rightMenu,
22
23
  leftMenu,
23
24
  }) => {
25
+ // The library's full-screen modals are an absolute-fill overlay inside
26
+ // ModalBackground (NOT a native Modal), so the OS doesn't apply any
27
+ // status-bar inset automatically — we have to. SafeAreaProvider is
28
+ // mounted by ReduxWrapper, so this reads the live per-device inset
29
+ // (notch / Dynamic Island / nothing).
30
+ const insets = useSafeAreaInsets();
24
31
  return (
25
- <HeaderContainer>
32
+ <HeaderContainer style={{ paddingTop: insets.top + 8 }}>
26
33
  <HeaderLeft>
27
34
  {leftMenu ? (
28
35
  leftMenu
@@ -72,10 +72,19 @@ export const HeaderContainer = styled.View`
72
72
  position: relative;
73
73
  top: 0;
74
74
  width: 100%;
75
- padding: 2px 8px;
76
- padding-top: 62px;
77
- max-height: 24px;
78
- min-height: 24px;
75
+ /* The previous incarnation had padding-top:62px (hard-coded status-bar
76
+ * offset) clamped to min/max-height:24px — content overflowed the
77
+ * visible band and the back arrow hugged the bottom edge. The earlier
78
+ * fix dropped the clamp but kept a hard-coded 56px top inset, which on
79
+ * top of this modal's absolute-fill overlay (no system inset is auto-
80
+ * applied) made the header eat far more space than it should.
81
+ *
82
+ * Now: compact 8px vertical padding here; the dynamic top inset for
83
+ * the status bar / Dynamic Island is supplied by ModalHeaderComponent
84
+ * via useSafeAreaInsets() — accurate per-device (~59px on a 16 Pro,
85
+ * 20px on an SE). Together that's status-bar + 8px above the back
86
+ * button and 8px below — properly sized and properly centered. */
87
+ padding: 8px 16px;
79
88
  background-color: #fff;
80
89
  border-bottom-width: 1px;
81
90
  border-bottom-color: #f0f0f0;
@@ -54,11 +54,14 @@ export const ChatContainerHeaderBoxInfo = styled.TouchableOpacity`
54
54
  flex: 1;
55
55
  flex-direction: row;
56
56
  align-items: center;
57
- justify-content: center;
57
+ /* Left-aligned: the avatar + chat-name row now hugs the left edge of
58
+ * the header instead of centering itself within the available space.
59
+ * Was: justify-content:center + max-width:90% which double-centered
60
+ * with CenterContainer and pushed the title to the middle. */
61
+ justify-content: flex-start;
62
+ width: 100%;
58
63
  overflow: hidden;
59
- max-width: 90%;
60
64
  gap: 16px;
61
- flex: 1;
62
65
  `;
63
66
 
64
67
  export const ChatContainerHeaderInfo = styled.View`
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect, useState } from 'react';
2
- import { TouchableOpacity, StyleSheet, View } from 'react-native';
2
+ import { Pressable, StyleSheet, View } from 'react-native';
3
3
  import { useVideoPlayer, VideoView } from 'expo-video';
4
4
  import { useDispatch } from 'react-redux';
5
5
  import {
@@ -48,9 +48,16 @@ const CustomMessageVideo: React.FC<CustomMessageVideoProps> = ({
48
48
  };
49
49
 
50
50
  return (
51
- <TouchableOpacity
52
- onPress={handleOpen}
53
- activeOpacity={0.9}
51
+ // Capture-overlay pattern: VideoView sits underneath without ANY
52
+ // touch participation; a transparent absolute-fill <Pressable> on
53
+ // top owns the tap. Previously we wrapped VideoView in a
54
+ // TouchableOpacity and set `pointerEvents="none"` on the inner
55
+ // VideoView — but on some expo-video versions / iOS the native
56
+ // VideoView still intercepted gestures despite that hint, so
57
+ // tapping the poster did nothing. Customer-reported #9 (video
58
+ // preview unresponsive). The Pressable now guarantees the tap
59
+ // reaches `handleOpen`.
60
+ <View
54
61
  style={[styles.wrapper, { width: dims.width, height: dims.height }]}
55
62
  >
56
63
  <VideoView
@@ -66,7 +73,16 @@ const CustomMessageVideo: React.FC<CustomMessageVideoProps> = ({
66
73
  <PlayIcon width={28} height={28} />
67
74
  </View>
68
75
  </View>
69
- </TouchableOpacity>
76
+ <Pressable
77
+ onPress={handleOpen}
78
+ // Sits ON TOP of VideoView + the play-button overlay; transparent
79
+ // so the poster + play icon show through. Captures the tap and
80
+ // routes to handleOpen → setActiveModal(FILE_PREVIEW).
81
+ style={StyleSheet.absoluteFill}
82
+ accessibilityRole="button"
83
+ accessibilityLabel="Play video"
84
+ />
85
+ </View>
70
86
  );
71
87
  };
72
88