@planningcenter/chat-react-native 3.44.1-qa-874.0 → 3.44.1-qa-876.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/build/components/conversation/attachments/giphy_attachment.js +2 -2
  2. package/build/components/conversation/attachments/giphy_attachment.js.map +1 -1
  3. package/build/components/conversation/attachments/image_attachment.d.ts.map +1 -1
  4. package/build/components/conversation/attachments/image_attachment.js +15 -14
  5. package/build/components/conversation/attachments/image_attachment.js.map +1 -1
  6. package/build/components/conversation/attachments/video_attachment.d.ts.map +1 -1
  7. package/build/components/conversation/attachments/video_attachment.js +2 -1
  8. package/build/components/conversation/attachments/video_attachment.js.map +1 -1
  9. package/build/components/conversation/message.d.ts.map +1 -1
  10. package/build/components/conversation/message.js +8 -1
  11. package/build/components/conversation/message.js.map +1 -1
  12. package/build/components/conversation/message_form.d.ts.map +1 -1
  13. package/build/components/conversation/message_form.js +7 -1
  14. package/build/components/conversation/message_form.js.map +1 -1
  15. package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
  16. package/build/components/conversation/reply_shadow_message.js +10 -3
  17. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  18. package/build/components/display/badge.d.ts.map +1 -1
  19. package/build/components/display/badge.js +16 -3
  20. package/build/components/display/badge.js.map +1 -1
  21. package/build/hooks/use_conversation_layout_diagnostics.d.ts +21 -0
  22. package/build/hooks/use_conversation_layout_diagnostics.d.ts.map +1 -0
  23. package/build/hooks/use_conversation_layout_diagnostics.js +99 -0
  24. package/build/hooks/use_conversation_layout_diagnostics.js.map +1 -0
  25. package/build/hooks/use_conversation_messages.d.ts +1 -0
  26. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  27. package/build/hooks/use_conversation_messages.js +10 -2
  28. package/build/hooks/use_conversation_messages.js.map +1 -1
  29. package/build/navigation/index.d.ts.map +1 -1
  30. package/build/navigation/index.js +2 -3
  31. package/build/navigation/index.js.map +1 -1
  32. package/build/screens/conversation_screen.d.ts.map +1 -1
  33. package/build/screens/conversation_screen.js +22 -5
  34. package/build/screens/conversation_screen.js.map +1 -1
  35. package/build/screens/message_report_screen.d.ts.map +1 -1
  36. package/build/screens/message_report_screen.js +2 -4
  37. package/build/screens/message_report_screen.js.map +1 -1
  38. package/build/utils/aspect_ratio.d.ts +2 -0
  39. package/build/utils/aspect_ratio.d.ts.map +1 -0
  40. package/build/utils/aspect_ratio.js +11 -0
  41. package/build/utils/aspect_ratio.js.map +1 -0
  42. package/build/utils/index.d.ts +1 -0
  43. package/build/utils/index.d.ts.map +1 -1
  44. package/build/utils/index.js +1 -0
  45. package/build/utils/index.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/__tests__/hooks/use_conversation_layout_diagnostics.test.tsx +142 -0
  48. package/src/components/conversation/attachments/giphy_attachment.tsx +2 -2
  49. package/src/components/conversation/attachments/image_attachment.tsx +30 -26
  50. package/src/components/conversation/attachments/video_attachment.tsx +2 -1
  51. package/src/components/conversation/message.tsx +8 -1
  52. package/src/components/conversation/message_form.tsx +14 -2
  53. package/src/components/conversation/reply_shadow_message.tsx +10 -3
  54. package/src/components/display/badge.tsx +20 -2
  55. package/src/hooks/use_conversation_layout_diagnostics.ts +123 -0
  56. package/src/hooks/use_conversation_messages.ts +10 -2
  57. package/src/navigation/index.tsx +2 -3
  58. package/src/screens/conversation_screen.tsx +25 -4
  59. package/src/screens/message_report_screen.tsx +6 -4
  60. package/src/utils/__tests__/aspect_ratio.test.ts +50 -0
  61. package/src/utils/aspect_ratio.ts +14 -0
  62. package/src/utils/index.ts +1 -0
@@ -3,6 +3,7 @@ import React, { useRef, useState } from 'react'
3
3
  import { View, StyleSheet } from 'react-native'
4
4
  import { useTheme } from '../../../hooks'
5
5
  import { DenormalizedMessageAttachmentResource } from '../../../types/resources/denormalized_attachment_resource'
6
+ import { safeAspectRatio } from '../../../utils'
6
7
  import { Video, VideoPlayerHandle } from '../../../utils/native_adapters'
7
8
  import { tokens } from '../../../vendor/tapestry/tokens'
8
9
  import { Icon } from '../../display'
@@ -58,7 +59,7 @@ export function VideoAttachment({
58
59
  <Video.Player
59
60
  ref={videoRef}
60
61
  source={{ uri: url }}
61
- aspectRatio={width / height}
62
+ aspectRatio={safeAspectRatio(width, height)}
62
63
  style={styles.video}
63
64
  onFullscreenPlayerWillDismiss={onFullscreenPlayerWillDismiss}
64
65
  />
@@ -17,6 +17,8 @@ import {
17
17
  useTheme,
18
18
  } from '../../hooks'
19
19
  import { useDeletingIds } from '../../hooks'
20
+ // CHAT-476 diagnostics - remove with use_conversation_layout_diagnostics.ts
21
+ import { useLayoutWidthProbe } from '../../hooks/use_conversation_layout_diagnostics'
20
22
  import { useConversationMessage } from '../../hooks/use_conversation_message'
21
23
  import { useLiveRelativeTime } from '../../hooks/use_live_relative_time'
22
24
  import { useMessageCreateOrUpdate } from '../../hooks/use_message_create_or_update'
@@ -69,6 +71,8 @@ export function Message({
69
71
  const isPersisted = !isNewMessage(message)
70
72
  const [temporarilyHidePendingState, setTemporarilyHidePendingState] = React.useState(true)
71
73
  const [messageBubbleHeight, setMessageBubbleHeight] = React.useState(0)
74
+ // CHAT-476 diagnostics - remove with use_conversation_layout_diagnostics.ts
75
+ const onMessageBubbleLayout = useLayoutWidthProbe('message.bubble', 0)
72
76
  const { animatedBackgroundColor, handleMessagePressIn, handleMessagePressOut } =
73
77
  useAnimatedMessageBackgroundColor()
74
78
  const { message: replyRootMessage } = useConversationMessage({
@@ -231,7 +235,10 @@ export function Message({
231
235
  )}
232
236
  <View
233
237
  style={styles.messageBubble}
234
- onLayout={e => setMessageBubbleHeight(e.nativeEvent.layout.height)}
238
+ onLayout={e => {
239
+ setMessageBubbleHeight(e.nativeEvent.layout.height)
240
+ onMessageBubbleLayout?.(e)
241
+ }}
235
242
  >
236
243
  {isDeletedReplyRootMessage ? (
237
244
  <View style={styles.messageText}>
@@ -24,6 +24,8 @@ import {
24
24
  import { useAttachmentUploader } from '../../hooks/use_attachment_uploader'
25
25
  import { useBroadcastTypingStatus } from '../../hooks/use_broadcast_typing_status'
26
26
  import { useChatConfiguration } from '../../hooks/use_chat_configuration'
27
+ // CHAT-476 diagnostics - remove with use_conversation_layout_diagnostics.ts
28
+ import { useLayoutWidthProbe } from '../../hooks/use_conversation_layout_diagnostics'
27
29
  import { useMessageCreateOrUpdate } from '../../hooks/use_message_create_or_update'
28
30
  import { useMessageDraft } from '../../hooks/use_message_draft'
29
31
  import { ConversationScreenProps } from '../../screens/conversation_screen'
@@ -430,6 +432,11 @@ function MessageFormInput() {
430
432
 
431
433
  const broadcastTypingStatus = useBroadcastTypingStatus()
432
434
 
435
+ // CHAT-476 diagnostics - remove with use_conversation_layout_diagnostics.ts
436
+ const onTextInputBoundaryLayout = useLayoutWidthProbe('form.textInputBoundary', 2)
437
+ const onInputErrorMessageLayout = useLayoutWidthProbe('form.errorText', 32)
438
+ const onTextInputLayout = useLayoutWidthProbe('form.textInput', 32)
439
+
433
440
  const handleTextChange = (newText: string) => {
434
441
  setText(newText)
435
442
 
@@ -448,9 +455,13 @@ function MessageFormInput() {
448
455
  }
449
456
 
450
457
  return (
451
- <View style={styles.textInputBoundary}>
458
+ <View style={styles.textInputBoundary} onLayout={onTextInputBoundaryLayout}>
452
459
  <MessageFormAttachments />
453
- {attachmentError ? <Text style={styles.inputErrorMessage}>{attachmentError}</Text> : null}
460
+ {attachmentError ? (
461
+ <Text style={styles.inputErrorMessage} onLayout={onInputErrorMessageLayout}>
462
+ {attachmentError}
463
+ </Text>
464
+ ) : null}
454
465
  <View style={styles.textInputRow}>
455
466
  {usingGiphy ? (
456
467
  <View style={styles.giphyBadge}>
@@ -472,6 +483,7 @@ function MessageFormInput() {
472
483
  onSubmitEditing={onSubmit}
473
484
  submitBehavior={usingGiphy ? 'submit' : 'newline'}
474
485
  editable={inputEditable}
486
+ onLayout={onTextInputLayout}
475
487
  />
476
488
  </View>
477
489
  </View>
@@ -9,6 +9,8 @@ import {
9
9
  useScalableNumberOfLines,
10
10
  useTheme,
11
11
  } from '../../hooks'
12
+ // CHAT-476 diagnostics - remove with use_conversation_layout_diagnostics.ts
13
+ import { useLayoutWidthProbe } from '../../hooks/use_conversation_layout_diagnostics'
12
14
  import { useConversationMessage } from '../../hooks/use_conversation_message'
13
15
  import { useLiveRelativeTime } from '../../hooks/use_live_relative_time'
14
16
  import { MessageResource } from '../../types'
@@ -18,7 +20,7 @@ import {
18
20
  DenormalizedExpandedLinkAttachmentResource,
19
21
  DenormalizedMessageAttachmentResource,
20
22
  } from '../../types/resources/denormalized_attachment_resource'
21
- import { assertKeysAreNumbers, pluralize } from '../../utils'
23
+ import { assertKeysAreNumbers, pluralize, safeAspectRatio } from '../../utils'
22
24
  import {
23
25
  CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL,
24
26
  MAX_FONT_SIZE_MULTIPLIER,
@@ -70,6 +72,8 @@ function ShadowMessageContent({ conversation_id, ...message }: ShadowMessageCont
70
72
  const timestamp = useLiveRelativeTime(message.createdAt)
71
73
 
72
74
  const [messageBubbleHeight, setMessageBubbleHeight] = React.useState(0)
75
+ // CHAT-476 diagnostics - remove with use_conversation_layout_diagnostics.ts
76
+ const onMessageBubbleLayout = useLayoutWidthProbe('replyShadow.bubble', 18)
73
77
  const { animatedBackgroundColor, handleMessagePressIn, handleMessagePressOut } =
74
78
  useAnimatedMessageBackgroundColor()
75
79
  const scalableNumberOfLines = useScalableNumberOfLines(2)
@@ -119,7 +123,10 @@ function ShadowMessageContent({ conversation_id, ...message }: ShadowMessageCont
119
123
  <View style={styles.messageContent}>
120
124
  <View
121
125
  style={styles.messageBubble}
122
- onLayout={e => setMessageBubbleHeight(e.nativeEvent.layout.height)}
126
+ onLayout={e => {
127
+ setMessageBubbleHeight(e.nativeEvent.layout.height)
128
+ onMessageBubbleLayout?.(e)
129
+ }}
123
130
  >
124
131
  <MessageAttachmentImagery attachments={attachments} />
125
132
  {text && (
@@ -324,7 +331,7 @@ const useStyles = ({ mine, imageWidth = 32, imageHeight = 32 }: StylesProps = {}
324
331
  },
325
332
  imageWrapper: {
326
333
  width: 32 * fontScale,
327
- aspectRatio: imageWidth / imageHeight,
334
+ aspectRatio: safeAspectRatio(imageWidth, imageHeight),
328
335
  opacity: 0.5,
329
336
  },
330
337
  image: {
@@ -3,6 +3,8 @@ import type { TextStyle, ViewProps, ViewStyle } from 'react-native'
3
3
  import { Pressable, StyleSheet, View } from 'react-native'
4
4
  import Svg, { Defs, LinearGradient, Path, Stop } from 'react-native-svg'
5
5
  import { useFontScale, useTheme } from '../../hooks'
6
+ // CHAT-476 diagnostics - remove with use_conversation_layout_diagnostics.ts
7
+ import { useLayoutWidthProbe } from '../../hooks/use_conversation_layout_diagnostics'
6
8
  import { platformFontWeightMedium } from '../../utils'
7
9
  import { tokens } from '../../vendor/tapestry/tokens'
8
10
  import { Icon, IconString } from './icon'
@@ -107,6 +109,14 @@ export function Badge({
107
109
  }: BadgeProps) {
108
110
  const styles = useStyles({ appearance, maxFontSizeMultiplier, variant })
109
111
  const { showBadgeProductLogo } = useTheme()
112
+ // CHAT-476 diagnostics - remove with use_conversation_layout_diagnostics.ts
113
+ // Inset is metaLabel's remaining contentInset now that its left offset is
114
+ // a margin, not padding - see the CHAT-476 candidate fix on this style.
115
+ const onLabelLayout = useLayoutWidthProbe('badge.label', 0)
116
+ const onMetaLabelLayout = useLayoutWidthProbe(
117
+ 'badge.metaLabel',
118
+ styles.metaLabel.paddingHorizontal * 2
119
+ )
110
120
 
111
121
  const isMetaSubtle = variant === 'metaSubtle'
112
122
  const hasMetaLabel = Boolean(metaLabel)
@@ -127,6 +137,7 @@ export function Badge({
127
137
  style={styles.label}
128
138
  numberOfLines={1}
129
139
  maxFontSizeMultiplier={maxFontSizeMultiplier}
140
+ onLayout={onLabelLayout}
130
141
  >
131
142
  {badgeLabel}
132
143
  </Text>
@@ -138,6 +149,7 @@ export function Badge({
138
149
  style={styles.metaLabel}
139
150
  numberOfLines={numberOfLinesMetaLabel}
140
151
  maxFontSizeMultiplier={maxFontSizeMultiplier}
152
+ onLayout={onMetaLabelLayout}
141
153
  >
142
154
  {metaLabel}
143
155
  </Text>
@@ -234,7 +246,10 @@ const useStyles = ({
234
246
  paddingVertical: variantStylesMap[variant].paddingVertical,
235
247
  paddingHorizontal: variantStylesMap[variant].paddingHorizontal,
236
248
  backgroundColor: variantStylesMap[variant].backgroundColor,
237
- borderRadius: variantStylesMap[variant].borderRadius - 2,
249
+ // metaSubtle's borderRadius is 0, so this would otherwise resolve to -2.
250
+ // Neither platform clamps a negative radius; Android's BackgroundDrawable
251
+ // reads it as "no rounded borders" and skips the rounded path entirely.
252
+ borderRadius: Math.max(0, variantStylesMap[variant].borderRadius - 2),
238
253
  },
239
254
  icon: {
240
255
  color: statusColorMap[appearance].icon,
@@ -258,7 +273,10 @@ const useStyles = ({
258
273
  fontSize: badgeFontSize,
259
274
  alignSelf: 'center',
260
275
  flexShrink: 1,
261
- paddingLeft: variantStylesMap[variant].metaLabelPaddingLeft,
276
+ // CHAT-476 candidate fix: margin (not padding) so this offset sits
277
+ // outside contentInsets - a shrunk Text can never see it as part of
278
+ // its own measured content width, so it can't go negative from this.
279
+ marginLeft: variantStylesMap[variant].metaLabelPaddingLeft,
262
280
  },
263
281
  })
264
282
  }
@@ -0,0 +1,123 @@
1
+ import { useCallback, useEffect, useRef } from 'react'
2
+ import { LayoutChangeEvent, PixelRatio, Platform, useWindowDimensions } from 'react-native'
3
+ import { Log } from '../utils'
4
+
5
+ const BREADCRUMB_INTERVAL_MS = 500
6
+ const MAX_BREADCRUMB_FLUSHES = 6
7
+ const SUSPICIOUS_MARGIN = 1
8
+
9
+ type LayoutSample = { width: number; height: number; x: number }
10
+
11
+ // Temporary instrumentation for CHAT-476 (Android-only `Layout: -2 < 0`
12
+ // conversation crash). Android's text layout hard-throws when a Text or
13
+ // TextInput resolves to a non-positive content width; this attaches to
14
+ // onLayout on suspect nodes and reports their resolved width through the
15
+ // host error reporter, so a QA repro surfaces which node it is before the
16
+ // native crash tears down the surface. Remove once CHAT-476 is resolved —
17
+ // this hook and every call site are tagged CHAT-476 for that reason.
18
+
19
+ const samplesByLabel = new Map<string, LayoutSample>()
20
+ const reportedSuspiciousLabels = new Set<string>()
21
+
22
+ // Test-only: the maps above are module-scope so useConversationLayoutDiagnostics
23
+ // can flush every probe's latest sample in one breadcrumb. Reset between tests.
24
+ export const __resetLayoutDiagnosticsForTests = () => {
25
+ samplesByLabel.clear()
26
+ reportedSuspiciousLabels.clear()
27
+ }
28
+
29
+ /**
30
+ * Attaches to a suspect Text/TextInput/View's onLayout and reports through
31
+ * Log.reportError the first time its resolved width drops to at or below
32
+ * its own horizontal inset (padding/border) — the shape that Android's
33
+ * unclamped text-measure path can turn into a negative width. Returns
34
+ * undefined on non-Android platforms so it's a no-op there.
35
+ */
36
+ export const useLayoutWidthProbe = (label: string, inset = 0) => {
37
+ const mountedAtRef = useRef(Date.now())
38
+
39
+ const handleLayout = useCallback(
40
+ (event: LayoutChangeEvent) => {
41
+ const { width, height, x } = event.nativeEvent.layout
42
+ const previous = samplesByLabel.get(label)
43
+ if (previous && previous.width === width && previous.height === height && previous.x === x) {
44
+ return
45
+ }
46
+ samplesByLabel.set(label, { width, height, x })
47
+
48
+ const isSuspicious = width <= inset + SUSPICIOUS_MARGIN
49
+ if (!isSuspicious || reportedSuspiciousLabels.has(label)) return
50
+
51
+ reportedSuspiciousLabels.add(label)
52
+ Log.reportError(new Error(`CHAT-476: ${label} measured at or below its horizontal inset`), {
53
+ scope: 'component',
54
+ tags: { team: 'chat', chat476: 'near-zero-width' },
55
+ extra: {
56
+ label,
57
+ width,
58
+ height,
59
+ x,
60
+ inset,
61
+ msSinceMount: Date.now() - mountedAtRef.current,
62
+ pixelRatio: PixelRatio.get(),
63
+ fontScale: PixelRatio.getFontScale(),
64
+ },
65
+ })
66
+ },
67
+ [label, inset]
68
+ )
69
+
70
+ if (Platform.OS !== 'android') return undefined
71
+
72
+ return handleLayout
73
+ }
74
+
75
+ /**
76
+ * Mount once per conversation screen. Periodically flushes every probed
77
+ * label's latest known width as a single breadcrumb, so there's a timeline
78
+ * leading up to the crash even if no individual probe crosses the
79
+ * suspicious threshold. Capped at MAX_BREADCRUMB_FLUSHES per mount.
80
+ */
81
+ export const useConversationLayoutDiagnostics = ({
82
+ conversationId,
83
+ replyRootId,
84
+ }: {
85
+ conversationId: number
86
+ replyRootId?: string | null
87
+ }) => {
88
+ const { width: windowWidth } = useWindowDimensions()
89
+ const mountedAtRef = useRef(Date.now())
90
+ const flushCountRef = useRef(0)
91
+
92
+ useEffect(() => {
93
+ if (Platform.OS !== 'android') return
94
+
95
+ samplesByLabel.clear()
96
+ reportedSuspiciousLabels.clear()
97
+ mountedAtRef.current = Date.now()
98
+ flushCountRef.current = 0
99
+
100
+ const interval = setInterval(() => {
101
+ if (flushCountRef.current >= MAX_BREADCRUMB_FLUSHES || samplesByLabel.size === 0) return
102
+
103
+ flushCountRef.current += 1
104
+ Log.reportError(new Error('CHAT-476: layout breadcrumb'), {
105
+ scope: 'component',
106
+ tags: { team: 'chat', chat476: 'breadcrumb' },
107
+ extra: {
108
+ conversationId,
109
+ replyRootId,
110
+ msSinceMount: Date.now() - mountedAtRef.current,
111
+ windowWidth,
112
+ pixelRatio: PixelRatio.get(),
113
+ fontScale: PixelRatio.getFontScale(),
114
+ samples: [...samplesByLabel.entries()].map(
115
+ ([label, { width, height, x }]) => `${label}|w=${width}|h=${height}|x=${x}`
116
+ ),
117
+ },
118
+ })
119
+ }, BREADCRUMB_INTERVAL_MS)
120
+
121
+ return () => clearInterval(interval)
122
+ }, [conversationId, replyRootId, windowWidth])
123
+ }
@@ -7,7 +7,7 @@ export const useConversationMessages = (
7
7
  { conversation_id, reply_root_id }: { conversation_id: number; reply_root_id?: string | null },
8
8
  opts?: SuspensePaginatorOptions
9
9
  ) => {
10
- const { data, refetch, isRefetching, fetchNextPage, hasNextPage } =
10
+ const { data, refetch, isRefetching, fetchNextPage, hasNextPage, isFetchingNextPage } =
11
11
  useSuspensePaginator<MessageResource>(
12
12
  getMessagesRequestArgs({ conversation_id, reply_root_id }),
13
13
  opts
@@ -25,5 +25,13 @@ export const useConversationMessages = (
25
25
  [data]
26
26
  )
27
27
 
28
- return { messages, refetch, isRefetching, fetchNextPage, hasNextPage, queryKey }
28
+ return {
29
+ messages,
30
+ refetch,
31
+ isRefetching,
32
+ fetchNextPage,
33
+ hasNextPage,
34
+ isFetchingNextPage,
35
+ queryKey,
36
+ }
29
37
  }
@@ -178,10 +178,9 @@ export const ChatStack = createNativeStackNavigator({
178
178
  composeOptions(
179
179
  {
180
180
  headerTitle: (route.params as { title?: string })?.title ?? 'Chat',
181
+ headerBackVisible: false,
181
182
  },
182
- withMinimalBack({
183
- onPress: navigation.goBack,
184
- })
183
+ withRightClose({ onPress: navigation.goBack })
185
184
  ),
186
185
  },
187
186
  ConversationFilters: {
@@ -32,6 +32,11 @@ import { useTheme } from '../hooks'
32
32
  import { useMessageAvailableMembersAction } from '../hooks/direct_messages/use_message_available_members_action'
33
33
  import { useConversation } from '../hooks/use_conversation'
34
34
  import { useConversationJoltEvents } from '../hooks/use_conversation_jolt_events'
35
+ // CHAT-476 diagnostics - remove with use_conversation_layout_diagnostics.ts
36
+ import {
37
+ useConversationLayoutDiagnostics,
38
+ useLayoutWidthProbe,
39
+ } from '../hooks/use_conversation_layout_diagnostics'
35
40
  import { useConversationMessages } from '../hooks/use_conversation_messages'
36
41
  import { useConversationMessagesJoltEvents } from '../hooks/use_conversation_messages_jolt_events'
37
42
  import { useMarkLatestMessageRead } from '../hooks/use_mark_latest_message_read'
@@ -91,12 +96,14 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
91
96
  const { conversation_id, editing_message_id, reply_root_id, reply_root_author_name } =
92
97
  route.params
93
98
  const { data: conversation } = useConversation(route.params)
94
- const { messages, fetchNextPage, hasNextPage } = useConversationMessages({
99
+ const { messages, fetchNextPage, hasNextPage, isFetchingNextPage } = useConversationMessages({
95
100
  conversation_id,
96
101
  reply_root_id,
97
102
  })
98
103
  useConversationJoltEvents({ conversationId: conversation_id })
99
104
  useConversationMessagesJoltEvents({ conversationId: conversation_id })
105
+ // CHAT-476 diagnostics - remove with use_conversation_layout_diagnostics.ts
106
+ useConversationLayoutDiagnostics({ conversationId: conversation_id, replyRootId: reply_root_id })
100
107
  const { markHandledByMessageForm } = useVideoModerationJoltEvents()
101
108
  useEnsureConversationsRouteExists()
102
109
  useMarkLatestMessageRead({ conversation, messages })
@@ -216,7 +223,11 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
216
223
  />
217
224
  )
218
225
  }}
219
- onEndReached={() => fetchNextPage()}
226
+ onEndReached={() => {
227
+ // VirtualizedList dedupes onEndReached per content length, not
228
+ // per fetch, so it can refire while a page is already in flight.
229
+ if (hasNextPage && !isFetchingNextPage) fetchNextPage()
230
+ }}
220
231
  ListHeaderComponent={<View style={styles.listHeader} />}
221
232
  />
222
233
  )}
@@ -289,11 +300,13 @@ function InlineDateSeparator({ date }: DateSeparator) {
289
300
  const { isThisYear } = getRelativeDateStatus(date)
290
301
  const showYear = !isThisYear
291
302
  const dateStamp = formatDate(date, { style: 'long', year: showYear })
303
+ // CHAT-476 diagnostics - remove with use_conversation_layout_diagnostics.ts
304
+ const onDateTextLayout = useLayoutWidthProbe('dateSeparator.text', 16)
292
305
 
293
306
  return (
294
307
  <View style={styles.container}>
295
308
  <View style={styles.separator} />
296
- <Text variant="footnote" style={styles.dateText}>
309
+ <Text variant="footnote" style={styles.dateText} onLayout={onDateTextLayout}>
297
310
  {dateStamp}
298
311
  </Text>
299
312
  <View style={styles.separator} />
@@ -342,11 +355,14 @@ export const ConversationScreenTitle = ({
342
355
  const resourceType = badge?.pcoResourceType || ''
343
356
  const productName = badge?.appName
344
357
  const name = badge?.text || undefined
358
+ // CHAT-476 diagnostics - remove with use_conversation_layout_diagnostics.ts
359
+ const onHeaderContainerLayout = useLayoutWidthProbe('header.container', 0)
345
360
 
346
361
  return (
347
362
  <PlatformPressable
348
363
  accessibilityHint="Opens details about members and settings"
349
364
  style={styles.container}
365
+ onLayout={onHeaderContainerLayout}
350
366
  onPress={() => {
351
367
  if (deleted) return
352
368
 
@@ -377,7 +393,12 @@ export const ConversationScreenTitle = ({
377
393
  const usePressableHeaderStyle = () => {
378
394
  const { width } = useWindowDimensions()
379
395
  const slotReserve = useHeaderSlotReserve()
380
- const headerContainerStyle = { maxWidth: width - slotReserve * 2 }
396
+
397
+ // Guards narrow windows (split-screen, freeform, DeX) where slotReserve * 2
398
+ // exceeds the available width. A negative maxWidth can hand a descendant
399
+ // <Text> a negative measure width, and android.text.Layout hard-throws on
400
+ // that (`Layout: -2 < 0`) rather than clamping the way iOS does.
401
+ const headerContainerStyle = { maxWidth: Math.max(0, width - slotReserve * 2) }
381
402
 
382
403
  return StyleSheet.create({
383
404
  container: {
@@ -30,9 +30,11 @@ export const MessageReportScreenOptions = ({
30
30
  }: NativeStackScreenProps<any>): NativeStackNavigationOptions => {
31
31
  const handleClose = () =>
32
32
  navigation.dispatch(
33
- StackActions.popTo('Conversation', {
34
- conversation_id: route.params?.conversation_id,
35
- })
33
+ StackActions.popTo(
34
+ 'Conversation',
35
+ { conversation_id: route.params?.conversation_id },
36
+ { merge: true }
37
+ )
36
38
  )
37
39
 
38
40
  return composeOptions(
@@ -100,7 +102,7 @@ function MessageReportScreenContent({
100
102
  }, [isFormValid, selectedReason, reasonDetail, submitReport])
101
103
 
102
104
  const handleClose = useCallback(() => {
103
- navigation.dispatch(StackActions.popTo('Conversation', { conversation_id }))
105
+ navigation.dispatch(StackActions.popTo('Conversation', { conversation_id }, { merge: true }))
104
106
  }, [navigation, conversation_id])
105
107
 
106
108
  const formVisible = status !== 'pending' && status !== 'success'
@@ -0,0 +1,50 @@
1
+ import { safeAspectRatio } from '../aspect_ratio'
2
+
3
+ describe('safeAspectRatio', () => {
4
+ it.each([
5
+ [200, 100, 2],
6
+ [100, 200, 0.5],
7
+ [100, 100, 1],
8
+ ])('divides width %p by height %p to get %p', (width, height, expected) => {
9
+ expect(safeAspectRatio(width, height)).toBe(expected)
10
+ })
11
+
12
+ describe('with values that would produce a non-positive or non-finite ratio', () => {
13
+ it.each([
14
+ [0, 100],
15
+ [100, 0],
16
+ [0, 0],
17
+ [-100, 100],
18
+ [100, -100],
19
+ [undefined, 100],
20
+ [100, undefined],
21
+ [undefined, undefined],
22
+ [NaN, 100],
23
+ [100, NaN],
24
+ [Infinity, 100],
25
+ ])('falls back to 1 for width %p, height %p', (width, height) => {
26
+ expect(safeAspectRatio(width, height)).toBe(1)
27
+ })
28
+ })
29
+
30
+ it('falls back to a caller-supplied value instead of the default', () => {
31
+ expect(safeAspectRatio(0, 100, 0.75)).toBe(0.75)
32
+ })
33
+
34
+ it('never returns 0, a negative number, or a non-finite number', () => {
35
+ const invertedFallbackCases: Array<[number | undefined, number | undefined]> = [
36
+ [0, 100],
37
+ [100, 0],
38
+ [-100, 100],
39
+ [NaN, 100],
40
+ [Infinity, 100],
41
+ ]
42
+
43
+ for (const [width, height] of invertedFallbackCases) {
44
+ const ratio = safeAspectRatio(width, height)
45
+ expect(ratio).not.toBe(0)
46
+ expect(Number.isFinite(ratio)).toBe(true)
47
+ expect(ratio).toBeGreaterThan(0)
48
+ }
49
+ })
50
+ })
@@ -0,0 +1,14 @@
1
+ // assertKeysAreNumbers coerces unparseable attachment dimensions to 0, so a
2
+ // raw width / height can be NaN or Infinity. Yoga ignores a NaN aspectRatio
3
+ // and resolves Infinity by collapsing the dependent dimension to 0, so both
4
+ // produce a silently broken box rather than an error.
5
+ export const safeAspectRatio = (
6
+ width: number | undefined,
7
+ height: number | undefined,
8
+ fallback = 1
9
+ ): number => {
10
+ if (!width || !height) return fallback
11
+
12
+ const ratio = width / height
13
+ return Number.isFinite(ratio) && ratio > 0 ? ratio : fallback
14
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './assert_keys_are_numbers'
2
+ export * from './aspect_ratio'
2
3
  export * from './attachment_kind'
3
4
  export * from './chat_type_label'
4
5
  export * from './cache'