@planningcenter/chat-react-native 3.44.1-qa-874.0 → 3.44.1-qa-875.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 (42) 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/reply_shadow_message.js +2 -2
  10. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  11. package/build/components/display/badge.js +3 -1
  12. package/build/components/display/badge.js.map +1 -1
  13. package/build/hooks/use_conversation_messages.d.ts +1 -0
  14. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  15. package/build/hooks/use_conversation_messages.js +10 -2
  16. package/build/hooks/use_conversation_messages.js.map +1 -1
  17. package/build/navigation/index.d.ts.map +1 -1
  18. package/build/navigation/index.js +2 -3
  19. package/build/navigation/index.js.map +1 -1
  20. package/build/screens/conversation_screen.d.ts.map +1 -1
  21. package/build/screens/conversation_screen.js +10 -3
  22. package/build/screens/conversation_screen.js.map +1 -1
  23. package/build/utils/aspect_ratio.d.ts +2 -0
  24. package/build/utils/aspect_ratio.d.ts.map +1 -0
  25. package/build/utils/aspect_ratio.js +10 -0
  26. package/build/utils/aspect_ratio.js.map +1 -0
  27. package/build/utils/index.d.ts +1 -0
  28. package/build/utils/index.d.ts.map +1 -1
  29. package/build/utils/index.js +1 -0
  30. package/build/utils/index.js.map +1 -1
  31. package/package.json +3 -3
  32. package/src/components/conversation/attachments/giphy_attachment.tsx +2 -2
  33. package/src/components/conversation/attachments/image_attachment.tsx +30 -26
  34. package/src/components/conversation/attachments/video_attachment.tsx +2 -1
  35. package/src/components/conversation/reply_shadow_message.tsx +2 -2
  36. package/src/components/display/badge.tsx +3 -1
  37. package/src/hooks/use_conversation_messages.ts +10 -2
  38. package/src/navigation/index.tsx +2 -3
  39. package/src/screens/conversation_screen.tsx +10 -3
  40. package/src/utils/__tests__/aspect_ratio.test.ts +50 -0
  41. package/src/utils/aspect_ratio.ts +13 -0
  42. package/src/utils/index.ts +1 -0
@@ -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';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAA;AACzC,cAAc,mBAAmB,CAAA;AACjC,cAAc,mBAAmB,CAAA;AACjC,cAAc,SAAS,CAAA;AACvB,cAAc,UAAU,CAAA;AACxB,cAAc,kCAAkC,CAAA;AAChD,cAAc,mCAAmC,CAAA;AACjD,cAAc,eAAe,CAAA;AAC7B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AACjC,cAAc,aAAa,CAAA;AAC3B,cAAc,iCAAiC,CAAA;AAC/C,cAAc,sBAAsB,CAAA;AACpC,cAAc,WAAW,CAAA;AACzB,cAAc,UAAU,CAAA;AACxB,cAAc,mBAAmB,CAAA;AACjC,cAAc,SAAS,CAAA;AACvB,cAAc,OAAO,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAA;AACzC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AACjC,cAAc,mBAAmB,CAAA;AACjC,cAAc,SAAS,CAAA;AACvB,cAAc,UAAU,CAAA;AACxB,cAAc,kCAAkC,CAAA;AAChD,cAAc,mCAAmC,CAAA;AACjD,cAAc,eAAe,CAAA;AAC7B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AACjC,cAAc,aAAa,CAAA;AAC3B,cAAc,iCAAiC,CAAA;AAC/C,cAAc,sBAAsB,CAAA;AACpC,cAAc,WAAW,CAAA;AACzB,cAAc,UAAU,CAAA;AACxB,cAAc,mBAAmB,CAAA;AACjC,cAAc,SAAS,CAAA;AACvB,cAAc,OAAO,CAAA"}
@@ -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';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAA;AACzC,cAAc,mBAAmB,CAAA;AACjC,cAAc,mBAAmB,CAAA;AACjC,cAAc,SAAS,CAAA;AACvB,cAAc,UAAU,CAAA;AACxB,cAAc,kCAAkC,CAAA;AAChD,cAAc,mCAAmC,CAAA;AACjD,cAAc,eAAe,CAAA;AAC7B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AACjC,cAAc,aAAa,CAAA;AAC3B,cAAc,iCAAiC,CAAA;AAC/C,cAAc,sBAAsB,CAAA;AACpC,cAAc,WAAW,CAAA;AACzB,cAAc,UAAU,CAAA;AACxB,cAAc,mBAAmB,CAAA;AACjC,cAAc,SAAS,CAAA;AACvB,cAAc,OAAO,CAAA","sourcesContent":["export * from './assert_keys_are_numbers'\nexport * from './attachment_kind'\nexport * from './chat_type_label'\nexport * from './cache'\nexport * from './client'\nexport * from './convert_attachments_for_create'\nexport * from './destructure_chat_group_graph_id'\nexport * from './host_bridge'\nexport * from './liquid_glass'\nexport * from './native_adapters'\nexport * from './pluralize'\nexport * from './conversation_safety_constants'\nexport * from './reaction_constants'\nexport * from './session'\nexport * from './styles'\nexport * from './system_messages'\nexport * from './theme'\nexport * from './uri'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/utils/index.ts"],"names":[],"mappings":"AAAA,cAAc,2BAA2B,CAAA;AACzC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AACjC,cAAc,mBAAmB,CAAA;AACjC,cAAc,SAAS,CAAA;AACvB,cAAc,UAAU,CAAA;AACxB,cAAc,kCAAkC,CAAA;AAChD,cAAc,mCAAmC,CAAA;AACjD,cAAc,eAAe,CAAA;AAC7B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AACjC,cAAc,aAAa,CAAA;AAC3B,cAAc,iCAAiC,CAAA;AAC/C,cAAc,sBAAsB,CAAA;AACpC,cAAc,WAAW,CAAA;AACzB,cAAc,UAAU,CAAA;AACxB,cAAc,mBAAmB,CAAA;AACjC,cAAc,SAAS,CAAA;AACvB,cAAc,OAAO,CAAA","sourcesContent":["export * from './assert_keys_are_numbers'\nexport * from './aspect_ratio'\nexport * from './attachment_kind'\nexport * from './chat_type_label'\nexport * from './cache'\nexport * from './client'\nexport * from './convert_attachments_for_create'\nexport * from './destructure_chat_group_graph_id'\nexport * from './host_bridge'\nexport * from './liquid_glass'\nexport * from './native_adapters'\nexport * from './pluralize'\nexport * from './conversation_safety_constants'\nexport * from './reaction_constants'\nexport * from './session'\nexport * from './styles'\nexport * from './system_messages'\nexport * from './theme'\nexport * from './uri'\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@planningcenter/chat-react-native",
3
- "version": "3.44.1-qa-874.0",
3
+ "version": "3.44.1-qa-875.0",
4
4
  "description": "",
5
5
  "main": "build/index.js",
6
6
  "react-native": "./src/index.tsx",
@@ -26,7 +26,7 @@
26
26
  "dependencies": {
27
27
  "@fortawesome/fontawesome-svg-core": "^7.2.0",
28
28
  "@fortawesome/react-native-fontawesome": "^0.3.2",
29
- "@planningcenter/emoji-keyboard": "3.44.1-qa-874.0",
29
+ "@planningcenter/emoji-keyboard": "3.44.1-qa-875.0",
30
30
  "lodash-inflection": "^1.5.0",
31
31
  "react-compiler-runtime": "^1.0.0"
32
32
  },
@@ -73,5 +73,5 @@
73
73
  "sf-symbols-typescript": "^2.2.0",
74
74
  "typescript": "~6.0.3"
75
75
  },
76
- "gitHead": "daeeded7a1784a81bbe37c495199a2fe5a0bf555"
76
+ "gitHead": "4a1e2d11d32c3d6b100d35c0aac5fe8d5fc60a59"
77
77
  }
@@ -3,7 +3,7 @@ import React from 'react'
3
3
  import { Text, StyleSheet, Linking } from 'react-native'
4
4
  import { useTheme } from '../../../hooks'
5
5
  import { DenormalizedGiphyAttachmentResource } from '../../../types/resources/denormalized_attachment_resource'
6
- import { assertKeysAreNumbers } from '../../../utils'
6
+ import { assertKeysAreNumbers, safeAspectRatio } from '../../../utils'
7
7
  import { tokens } from '../../../vendor/tapestry/tokens'
8
8
  import { Image } from '../../display'
9
9
 
@@ -55,7 +55,7 @@ const useStyles = ({ imageWidth, imageHeight }: { imageWidth: number; imageHeigh
55
55
  },
56
56
  imageWrapper: {
57
57
  width: '100%',
58
- aspectRatio: imageWidth / imageHeight,
58
+ aspectRatio: safeAspectRatio(imageWidth, imageHeight),
59
59
  },
60
60
  image: {
61
61
  borderRadius: 8,
@@ -7,9 +7,9 @@ import {
7
7
  Modal,
8
8
  View,
9
9
  Linking,
10
- Dimensions,
11
10
  NativeSyntheticEvent,
12
11
  NativeScrollEvent,
12
+ useWindowDimensions,
13
13
  ViewToken,
14
14
  } from 'react-native'
15
15
  import {
@@ -30,13 +30,12 @@ import Animated, {
30
30
  import { useSafeAreaInsets, SafeAreaProvider } from 'react-native-safe-area-context'
31
31
  import { useTheme } from '../../../hooks'
32
32
  import { DenormalizedMessageAttachmentResource } from '../../../types/resources/denormalized_attachment_resource'
33
- import { Haptic, platformFontWeightMedium } from '../../../utils'
33
+ import { Haptic, platformFontWeightMedium, safeAspectRatio } from '../../../utils'
34
34
  import { formatDatePreview } from '../../../utils/date'
35
35
  import { tokens } from '../../../vendor/tapestry/tokens'
36
36
  import { IconButton, Image, Heading, Text } from '../../display'
37
37
  import { AttachmentDeletingOverlay } from './attachment_deleting_overlay'
38
38
 
39
- const { width: WINDOW_WIDTH, height: WINDOW_HEIGHT } = Dimensions.get('window')
40
39
  const DISMISS_PAN_THRESHOLD = 175
41
40
  const MIN_DISTANCE_FOR_PAN = 10 // Higher threshold gives pinching priority
42
41
  const SINGLE_FINGER_POINTER = 1 // Single-finger panning / tapping helps to avoid conflicts with pinching
@@ -167,6 +166,7 @@ const LightboxGallery = ({
167
166
  }: LightboxGalleryProps) => {
168
167
  const styles = useStyles()
169
168
  const insets = useSafeAreaInsets()
169
+ const { width: windowWidth, height: windowHeight } = useWindowDimensions()
170
170
  const [currentImageIndex, setCurrentImageIndex] = useState(initialImageIndex)
171
171
  const { soft: triggerMaxZoomHaptic, rigid: triggerDismissHaptic } = Haptic
172
172
 
@@ -177,8 +177,8 @@ const LightboxGallery = ({
177
177
  const imageHeight = metadata.height
178
178
 
179
179
  // Calculate available space for image display
180
- const availableWindowWidth = WINDOW_WIDTH
181
- const availableWindowHeight = WINDOW_HEIGHT - insets.top - insets.bottom
180
+ const availableWindowWidth = windowWidth
181
+ const availableWindowHeight = windowHeight - insets.top - insets.bottom
182
182
 
183
183
  /* ============================
184
184
  ANIMATION VALUES
@@ -299,8 +299,8 @@ const LightboxGallery = ({
299
299
  'worklet'
300
300
 
301
301
  // How far the focal point is from the center of the window
302
- const windowCenterX = WINDOW_WIDTH / 2
303
- const windowCenterY = WINDOW_HEIGHT / 2
302
+ const windowCenterX = windowWidth / 2
303
+ const windowCenterY = windowHeight / 2
304
304
 
305
305
  // Position of focal point relative to current image position
306
306
  const focalRelativeX = focalPointX - windowCenterX - currentSavedTranslateX
@@ -402,29 +402,32 @@ const LightboxGallery = ({
402
402
  // Used in tandem with FlatList's initialScrollIndex to quickly calculate the position and size of each image before they load.
403
403
  const getItemLayout = useCallback(
404
404
  (_: unknown, index: number) => ({
405
- length: WINDOW_WIDTH,
406
- offset: WINDOW_WIDTH * index,
405
+ length: windowWidth,
406
+ offset: windowWidth * index,
407
407
  index,
408
408
  }),
409
- []
409
+ [windowWidth]
410
410
  )
411
411
 
412
412
  // Captures the current image's index after the FlatList finishes its scroll animation.
413
413
  // Used in tandem with onViewableItemsChanged to ensure the final value for currentImageIndex is set.
414
- const onMomentumScrollEnd = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
415
- // Calculate the index of the image that is currently visible
416
- const imageOffsetX = event.nativeEvent.contentOffset.x
417
- const newImageIndex = Math.round(imageOffsetX / WINDOW_WIDTH)
418
-
419
- // Check if the image index has changed and the FlatList didn't scroll past the first or last image
420
- const didImageIndexChange = newImageIndex !== stableCurrentImageIndexRef.current
421
- const isImageIndexWithinBounds =
422
- newImageIndex >= 0 && newImageIndex < stableImageAttachmentsLengthRef.current
423
-
424
- if (didImageIndexChange && isImageIndexWithinBounds) {
425
- setCurrentImageIndex(newImageIndex)
426
- }
427
- }, [])
414
+ const onMomentumScrollEnd = useCallback(
415
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
416
+ // Calculate the index of the image that is currently visible
417
+ const imageOffsetX = event.nativeEvent.contentOffset.x
418
+ const newImageIndex = Math.round(imageOffsetX / windowWidth)
419
+
420
+ // Check if the image index has changed and the FlatList didn't scroll past the first or last image
421
+ const didImageIndexChange = newImageIndex !== stableCurrentImageIndexRef.current
422
+ const isImageIndexWithinBounds =
423
+ newImageIndex >= 0 && newImageIndex < stableImageAttachmentsLengthRef.current
424
+
425
+ if (didImageIndexChange && isImageIndexWithinBounds) {
426
+ setCurrentImageIndex(newImageIndex)
427
+ }
428
+ },
429
+ [windowWidth]
430
+ )
428
431
 
429
432
  // Supplements onMomentumScrollEnd by capturing the current image's index while the FlatList is actively scrolling.
430
433
  // Used in tandem with viewabilityConfig to trigger when the image is 50% visible in the window.
@@ -929,6 +932,7 @@ interface UseStylesProps {
929
932
 
930
933
  const useStyles = ({ imageWidth = 100, imageHeight = 100 }: UseStylesProps = {}) => {
931
934
  const { top, bottom } = useSafeAreaInsets()
935
+ const { width: windowWidth } = useWindowDimensions()
932
936
  const backgroundColor = tokens.colorNeutral7
933
937
  const transparentBackgroundColor = useMemo(
934
938
  () => colorFunction(backgroundColor).alpha(0.8).toString(),
@@ -946,7 +950,7 @@ const useStyles = ({ imageWidth = 100, imageHeight = 100 }: UseStylesProps = {})
946
950
  attachmentImageWrapper: {
947
951
  width: '100%',
948
952
  minWidth: 200,
949
- aspectRatio: imageWidth / imageHeight,
953
+ aspectRatio: safeAspectRatio(imageWidth, imageHeight),
950
954
  },
951
955
  image: {
952
956
  borderRadius: 8,
@@ -960,7 +964,7 @@ const useStyles = ({ imageWidth = 100, imageHeight = 100 }: UseStylesProps = {})
960
964
  },
961
965
  gestureImage: {
962
966
  height: '100%',
963
- width: WINDOW_WIDTH,
967
+ width: windowWidth,
964
968
  backgroundColor: 'transparent',
965
969
  },
966
970
  gestureImageLoading: {
@@ -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
  />
@@ -18,7 +18,7 @@ import {
18
18
  DenormalizedExpandedLinkAttachmentResource,
19
19
  DenormalizedMessageAttachmentResource,
20
20
  } from '../../types/resources/denormalized_attachment_resource'
21
- import { assertKeysAreNumbers, pluralize } from '../../utils'
21
+ import { assertKeysAreNumbers, pluralize, safeAspectRatio } from '../../utils'
22
22
  import {
23
23
  CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL,
24
24
  MAX_FONT_SIZE_MULTIPLIER,
@@ -324,7 +324,7 @@ const useStyles = ({ mine, imageWidth = 32, imageHeight = 32 }: StylesProps = {}
324
324
  },
325
325
  imageWrapper: {
326
326
  width: 32 * fontScale,
327
- aspectRatio: imageWidth / imageHeight,
327
+ aspectRatio: safeAspectRatio(imageWidth, imageHeight),
328
328
  opacity: 0.5,
329
329
  },
330
330
  image: {
@@ -234,7 +234,9 @@ const useStyles = ({
234
234
  paddingVertical: variantStylesMap[variant].paddingVertical,
235
235
  paddingHorizontal: variantStylesMap[variant].paddingHorizontal,
236
236
  backgroundColor: variantStylesMap[variant].backgroundColor,
237
- borderRadius: variantStylesMap[variant].borderRadius - 2,
237
+ // metaSubtle's borderRadius is 0, so this can't go below 0 - Android
238
+ // hard-throws on a negative borderRadius during layout.
239
+ borderRadius: Math.max(0, variantStylesMap[variant].borderRadius - 2),
238
240
  },
239
241
  icon: {
240
242
  color: statusColorMap[appearance].icon,
@@ -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: {
@@ -91,7 +91,7 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
91
91
  const { conversation_id, editing_message_id, reply_root_id, reply_root_author_name } =
92
92
  route.params
93
93
  const { data: conversation } = useConversation(route.params)
94
- const { messages, fetchNextPage, hasNextPage } = useConversationMessages({
94
+ const { messages, fetchNextPage, hasNextPage, isFetchingNextPage } = useConversationMessages({
95
95
  conversation_id,
96
96
  reply_root_id,
97
97
  })
@@ -216,7 +216,11 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
216
216
  />
217
217
  )
218
218
  }}
219
- onEndReached={() => fetchNextPage()}
219
+ onEndReached={() => {
220
+ // Android's inverted FlatList can refire onEndReached while a
221
+ // page fetch is already in flight, queueing unbounded requests.
222
+ if (hasNextPage && !isFetchingNextPage) fetchNextPage()
223
+ }}
220
224
  ListHeaderComponent={<View style={styles.listHeader} />}
221
225
  />
222
226
  )}
@@ -377,7 +381,10 @@ export const ConversationScreenTitle = ({
377
381
  const usePressableHeaderStyle = () => {
378
382
  const { width } = useWindowDimensions()
379
383
  const slotReserve = useHeaderSlotReserve()
380
- const headerContainerStyle = { maxWidth: width - slotReserve * 2 }
384
+
385
+ // Guards split-screen/DeX widths where slotReserve * 2 exceeds the
386
+ // available width, which would otherwise resolve to a negative maxWidth.
387
+ const headerContainerStyle = { maxWidth: Math.max(0, width - slotReserve * 2) }
381
388
 
382
389
  return StyleSheet.create({
383
390
  container: {
@@ -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,13 @@
1
+ // Android hard-throws when a <Text>/<Image> is measured with a non-positive
2
+ // resolved width (`Layout: -2 < 0`), so any aspectRatio derived from
3
+ // attachment metadata must never resolve to 0, negative, or non-finite.
4
+ export const safeAspectRatio = (
5
+ width: number | undefined,
6
+ height: number | undefined,
7
+ fallback = 1
8
+ ): number => {
9
+ if (!width || !height) return fallback
10
+
11
+ const ratio = width / height
12
+ return Number.isFinite(ratio) && ratio > 0 ? ratio : fallback
13
+ }
@@ -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'