@planningcenter/chat-react-native 3.35.0-rc.1 → 3.35.0-rc.3

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 (40) hide show
  1. package/build/contexts/conversation_context.d.ts +6 -1
  2. package/build/contexts/conversation_context.d.ts.map +1 -1
  3. package/build/contexts/conversation_context.js +13 -3
  4. package/build/contexts/conversation_context.js.map +1 -1
  5. package/build/hooks/use_conversation_messages.d.ts +13 -6
  6. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  7. package/build/hooks/use_conversation_messages.js +56 -7
  8. package/build/hooks/use_conversation_messages.js.map +1 -1
  9. package/build/hooks/use_features.d.ts +1 -0
  10. package/build/hooks/use_features.d.ts.map +1 -1
  11. package/build/hooks/use_features.js +1 -0
  12. package/build/hooks/use_features.js.map +1 -1
  13. package/build/hooks/use_suspense_api.d.ts +1 -0
  14. package/build/hooks/use_suspense_api.d.ts.map +1 -1
  15. package/build/hooks/use_suspense_api.js +1 -1
  16. package/build/hooks/use_suspense_api.js.map +1 -1
  17. package/build/screens/conversation_screen.d.ts +1 -19
  18. package/build/screens/conversation_screen.d.ts.map +1 -1
  19. package/build/screens/conversation_screen.js +11 -100
  20. package/build/screens/conversation_screen.js.map +1 -1
  21. package/build/utils/conversation_messages.d.ts +10 -0
  22. package/build/utils/conversation_messages.d.ts.map +1 -0
  23. package/build/utils/conversation_messages.js +22 -0
  24. package/build/utils/conversation_messages.js.map +1 -0
  25. package/build/utils/group_messages.d.ts +21 -0
  26. package/build/utils/group_messages.d.ts.map +1 -0
  27. package/build/utils/group_messages.js +123 -0
  28. package/build/utils/group_messages.js.map +1 -0
  29. package/package.json +2 -2
  30. package/src/__tests__/hooks/use_conversation_messages.test.tsx +109 -0
  31. package/src/contexts/conversation_context.tsx +28 -2
  32. package/src/hooks/use_conversation_messages.ts +105 -21
  33. package/src/hooks/use_features.ts +1 -0
  34. package/src/hooks/use_suspense_api.ts +1 -1
  35. package/src/screens/conversation_screen.tsx +14 -131
  36. package/src/utils/__tests__/conversation_messages.test.ts +105 -0
  37. package/src/utils/__tests__/group_messages.test.ts +143 -0
  38. package/src/utils/conversation_messages.ts +37 -0
  39. package/src/utils/group_messages.ts +177 -0
  40. package/src/__tests__/hooks/use_conversation_messages.ts +0 -55
@@ -1,28 +1,112 @@
1
+ import {
2
+ AnyUseSuspenseInfiniteQueryOptions,
3
+ InfiniteData,
4
+ useSuspenseInfiniteQuery,
5
+ useSuspenseQueries,
6
+ } from '@tanstack/react-query'
1
7
  import { useMemo } from 'react'
2
- import { MessageResource } from '../types'
8
+ import { useConversationContext } from '../contexts/conversation_context'
9
+ import { ApiCollection, MessageResource } from '../types'
10
+ import {
11
+ anchoredSeedPageParams,
12
+ MessagesPageParam,
13
+ newerPageParam,
14
+ olderPageParam,
15
+ sortAndFilterMessages,
16
+ } from '../utils/conversation_messages'
3
17
  import { getMessagesQueryKey, getMessagesRequestArgs } from '../utils/request/get_messages'
4
- import { SuspensePaginatorOptions, useSuspensePaginator } from './use_suspense_api'
18
+ import { useApiClient } from './use_api_client'
19
+ import { throwResponseError } from './use_suspense_api'
20
+
21
+ type Args = { conversation_id: number; reply_root_id?: string | null }
22
+
23
+ export type ConversationMessagesOptions = Omit<
24
+ AnyUseSuspenseInfiniteQueryOptions,
25
+ | 'getNextPageParam'
26
+ | 'getPreviousPageParam'
27
+ | 'initialData'
28
+ | 'initialPageParam'
29
+ | 'queryFn'
30
+ | 'queryKey'
31
+ >
5
32
 
6
33
  export const useConversationMessages = (
7
- { conversation_id, reply_root_id }: { conversation_id: number; reply_root_id?: string | null },
8
- opts?: SuspensePaginatorOptions
34
+ { conversation_id, reply_root_id }: Args,
35
+ opts?: ConversationMessagesOptions
9
36
  ) => {
10
- const { data, refetch, isRefetching, fetchNextPage } = useSuspensePaginator<MessageResource>(
11
- getMessagesRequestArgs({ conversation_id, reply_root_id }),
12
- opts
13
- )
37
+ const apiClient = useApiClient()
38
+ const { initialMessageId } = useConversationContext()
39
+ const anchored = !reply_root_id && !!initialMessageId
40
+
41
+ const requestArgs = getMessagesRequestArgs({ conversation_id, reply_root_id })
14
42
  const queryKey = getMessagesQueryKey({ conversation_id, reply_root_id })
15
- const messages = useMemo(
16
- () =>
17
- data
18
- .filter(
19
- message =>
20
- (!message.deletedAt || message.replyRootId) &&
21
- (message.attachments?.length || message.text?.length)
22
- )
23
- .sort((a, b) => -a.id.localeCompare(b.id)),
24
- [data]
25
- )
26
-
27
- return { messages, refetch, isRefetching, fetchNextPage, queryKey }
43
+
44
+ const fetchPage = (pageParam: MessagesPageParam) => {
45
+ const data = {
46
+ ...requestArgs.data,
47
+ ...(pageParam.where ? { where: pageParam.where } : {}),
48
+ ...(pageParam.order ? { order: pageParam.order } : {}),
49
+ }
50
+ return apiClient.chat
51
+ .get<ApiCollection<MessageResource>>({ url: requestArgs.url, data })
52
+ .catch(throwResponseError)
53
+ }
54
+
55
+ const seedPageParams = anchored ? anchoredSeedPageParams(initialMessageId) : []
56
+ const seedQueries = useSuspenseQueries({
57
+ queries: seedPageParams.map((pageParam, index) => ({
58
+ queryKey: [...queryKey, 'seed', index],
59
+ queryFn: () => fetchPage(pageParam),
60
+ staleTime: Infinity,
61
+ gcTime: 0,
62
+ })),
63
+ })
64
+
65
+ const initialData: InfiniteData<ApiCollection<MessageResource>, MessagesPageParam> | undefined =
66
+ anchored
67
+ ? {
68
+ pages: seedQueries.map(q => q.data),
69
+ pageParams: seedPageParams,
70
+ }
71
+ : undefined
72
+
73
+ const initialPageParam: MessagesPageParam = anchored ? seedPageParams[0] : {}
74
+
75
+ const {
76
+ data,
77
+ refetch,
78
+ isRefetching,
79
+ fetchNextPage,
80
+ hasNextPage,
81
+ fetchPreviousPage,
82
+ hasPreviousPage,
83
+ } = useSuspenseInfiniteQuery<
84
+ ApiCollection<MessageResource>,
85
+ Response,
86
+ InfiniteData<ApiCollection<MessageResource>, MessagesPageParam>,
87
+ typeof queryKey,
88
+ MessagesPageParam
89
+ >({
90
+ queryKey,
91
+ queryFn: ({ pageParam }) => fetchPage(pageParam),
92
+ initialPageParam,
93
+ initialData,
94
+ getNextPageParam: olderPageParam,
95
+ getPreviousPageParam: anchored ? newerPageParam : () => undefined,
96
+ ...(opts || {}),
97
+ ...(anchored ? { staleTime: Infinity, refetchOnMount: false } : {}),
98
+ })
99
+
100
+ const messages = useMemo(() => sortAndFilterMessages(data.pages), [data.pages])
101
+
102
+ return {
103
+ messages,
104
+ refetch,
105
+ isRefetching,
106
+ fetchOlderMessages: fetchNextPage,
107
+ hasMoreOlderMessages: hasNextPage,
108
+ fetchNewerMessages: fetchPreviousPage,
109
+ hasMoreNewerMessages: hasPreviousPage,
110
+ queryKey,
111
+ }
28
112
  }
@@ -38,6 +38,7 @@ export const availableFeatures = {
38
38
  message_reporting: 'ROLLOUT_MOBILE_message_reporting',
39
39
  granular_notifications_ui: 'ROLLOUT_granular_notification_preferences_ui',
40
40
  custom_conversation_avatars: 'ROLLOUT_custom_conversation_avatars',
41
+ jump_to_unread: 'ROLLOUT_jump_to_unread',
41
42
  }
42
43
 
43
44
  const stableEmptyFeatures: ApiCollection<FeatureResource> = {
@@ -90,7 +90,7 @@ export const useSuspensePaginator = <T extends ResourceObject>(
90
90
  return { ...query, data, totalCount }
91
91
  }
92
92
 
93
- const throwResponseError = (error: unknown) => {
93
+ export const throwResponseError = (error: unknown) => {
94
94
  if (error instanceof Response) {
95
95
  throw new ResponseError(error as FailedResponse)
96
96
  }
@@ -31,15 +31,15 @@ import { useConversation } from '../hooks/use_conversation'
31
31
  import { useConversationJoltEvents } from '../hooks/use_conversation_jolt_events'
32
32
  import { useConversationMessages } from '../hooks/use_conversation_messages'
33
33
  import { useConversationMessagesJoltEvents } from '../hooks/use_conversation_messages_jolt_events'
34
+ import { useFeatures } from '../hooks/use_features'
34
35
  import { useMarkLatestMessageRead } from '../hooks/use_mark_latest_message_read'
35
36
  import {
36
37
  normalizeAnalyticsMetadata,
37
38
  usePublishProductAnalyticsEvent,
38
39
  } from '../hooks/use_product_analytics'
39
- import { MessageResource } from '../types'
40
40
  import { ConversationBadgeResource } from '../types/resources/conversation_badge'
41
41
  import { getRelativeDateStatus } from '../utils/date'
42
- import dayjs from '../utils/dayjs'
42
+ import { groupMessages, type DateSeparator } from '../utils/group_messages'
43
43
  import { CONVERSATION_MESSAGE_LIST_PADDING_HORIZONTAL } from '../utils/styles'
44
44
  import { isSystemMessage } from '../utils/system_messages'
45
45
 
@@ -50,6 +50,7 @@ export type ConversationRouteProps = {
50
50
  chat_group_graph_id?: string
51
51
  clear_input?: boolean
52
52
  editing_message_id?: number | null
53
+ message_id?: string
53
54
  title?: string
54
55
  subtitle?: string
55
56
  badge?: ConversationBadgeResource
@@ -60,19 +61,27 @@ export type ConversationRouteProps = {
60
61
  export type ConversationScreenProps = StaticScreenProps<ConversationRouteProps>
61
62
 
62
63
  export function ConversationScreen({ route }: ConversationScreenProps) {
63
- const { conversation_id, reply_root_id } = route.params
64
+ const { conversation_id, message_id, reply_root_id } = route.params
64
65
 
65
66
  const { data: conversation } = useConversation({ conversation_id })
67
+ const { featureEnabled } = useFeatures()
66
68
 
67
69
  usePublishProductAnalyticsEvent('chat.mobile.conversations.show.opened', {
68
70
  reply_root_id,
69
71
  ...normalizeAnalyticsMetadata(conversation),
70
72
  })
71
73
 
74
+ const lastReadMessageSortKey = conversation.conversationMembership?.lastReadMessageSortKey ?? null
75
+ const jumpToUnreadAnchor = featureEnabled('jump_to_unread') ? lastReadMessageSortKey : null
76
+ const initialMessageId = message_id ?? jumpToUnreadAnchor
77
+ const initialMessageIdIsAnchor = !!initialMessageId && !message_id
78
+
72
79
  return (
73
80
  <ConversationContextProvider
74
81
  conversationId={conversation_id}
75
82
  currentPageReplyRootId={reply_root_id ?? null}
83
+ initialMessageId={initialMessageId}
84
+ initialMessageIdIsAnchor={initialMessageIdIsAnchor}
76
85
  >
77
86
  <ConversationScreenContent route={route} />
78
87
  </ConversationContextProvider>
@@ -85,7 +94,7 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
85
94
  const { conversation_id, editing_message_id, reply_root_id, reply_root_author_name } =
86
95
  route.params
87
96
  const { data: conversation } = useConversation(route.params)
88
- const { messages, refetch, isRefetching, fetchNextPage } = useConversationMessages({
97
+ const { messages, refetch, isRefetching, fetchOlderMessages } = useConversationMessages({
89
98
  conversation_id,
90
99
  reply_root_id,
91
100
  })
@@ -204,7 +213,7 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
204
213
  />
205
214
  )
206
215
  }}
207
- onEndReached={() => fetchNextPage()}
216
+ onEndReached={() => fetchOlderMessages()}
208
217
  ListHeaderComponent={<View style={styles.listHeader} />}
209
218
  />
210
219
  )}
@@ -238,8 +247,6 @@ function ConversationScreenContent({ route }: ConversationScreenProps) {
238
247
  )
239
248
  }
240
249
 
241
- export type DateSeparator = { type: 'DateSeparator'; id: string; date: string }
242
-
243
250
  function InlineDateSeparator({ date }: DateSeparator) {
244
251
  const styles = useDateSeparatorStyles()
245
252
  const { isThisYear } = getRelativeDateStatus(date)
@@ -278,130 +285,6 @@ const useDateSeparatorStyles = () => {
278
285
  })
279
286
  }
280
287
 
281
- type ReplyShadowMessage = {
282
- type: 'ReplyShadowMessage'
283
- id: string
284
- messageId: string
285
- isReplyShadowMessage: boolean
286
- nextRendersAuthor: boolean
287
- }
288
-
289
- interface GroupMessagesProps {
290
- ms: MessageResource[]
291
- inReplyScreen?: boolean
292
- }
293
-
294
- export const groupMessages = ({ ms, inReplyScreen }: GroupMessagesProps) => {
295
- let enrichedMessages: (MessageResource | DateSeparator | ReplyShadowMessage)[] = []
296
- let encounteredOneOfMyMessages = false
297
-
298
- ms.forEach((message, i) => {
299
- const prevMessage = ms[i + 1]
300
- const nextMessage = ms[i - 1]
301
- const date = dayjs(message.createdAt).format('YYYY-MM-DD')
302
-
303
- const prevMessageIsDateSeparator =
304
- prevMessage && date !== dayjs(prevMessage.createdAt).format('YYYY-MM-DD')
305
-
306
- if (isSystemMessage(message)) {
307
- message.myLatestInConversation = false
308
- message.lastInGroup = true
309
- message.renderAuthor = false
310
- message.nextRendersAuthor = nextMessage?.renderAuthor
311
- message.isReplyShadowMessage = false
312
- message.nextIsReplyShadowMessage = false
313
- message.threadPosition = null
314
- enrichedMessages.push(message)
315
- if (prevMessageIsDateSeparator) {
316
- enrichedMessages.push({ type: 'DateSeparator', id: `day-divider-${message.id}`, date })
317
- }
318
- return
319
- }
320
-
321
- const inThread = message.replyRootId !== null
322
- const nextMessageInThread = nextMessage?.replyRootId !== null
323
- const threadRoot = message.replyRootId === message.id
324
- const nextMessageThreadRoot = nextMessage?.replyRootId === nextMessage?.id
325
- const prevMessageDifferentThread = message.replyRootId !== prevMessage?.replyRootId
326
- const nextMessageDifferentThread = message.replyRootId !== nextMessage?.replyRootId
327
- const prevMessageDifferentAuthor = message.author?.id !== prevMessage?.author?.id
328
- const nextMessageDifferentAuthor = message.author?.id !== nextMessage?.author?.id
329
- const prevMessageMoreThan5Minutes =
330
- prevMessage &&
331
- new Date(message.createdAt).getTime() - new Date(prevMessage.createdAt).getTime() > 60000 * 5
332
- const nextMessageMoreThan5Minutes =
333
- nextMessage &&
334
- new Date(nextMessage.createdAt).getTime() - new Date(message.createdAt).getTime() > 60000 * 5
335
- const nextMessageIsDateSeparator =
336
- nextMessage && date !== dayjs(nextMessage.createdAt).format('YYYY-MM-DD')
337
- const insertReplyShadowMessage =
338
- message.replyRootId &&
339
- !threadRoot &&
340
- (prevMessageDifferentThread || prevMessageIsDateSeparator)
341
- const lastInGroup =
342
- !nextMessage ||
343
- nextMessageDifferentAuthor ||
344
- nextMessageMoreThan5Minutes ||
345
- nextMessageDifferentThread ||
346
- nextMessageIsDateSeparator
347
- const renderAuthor =
348
- !message.mine &&
349
- (!prevMessage ||
350
- prevMessageDifferentAuthor ||
351
- prevMessageMoreThan5Minutes ||
352
- prevMessageDifferentThread ||
353
- prevMessageIsDateSeparator)
354
- const nextIsReplyShadowMessage =
355
- nextMessageInThread &&
356
- !nextMessageThreadRoot &&
357
- (nextMessageDifferentThread || nextMessageIsDateSeparator)
358
-
359
- if (message.mine && !encounteredOneOfMyMessages) {
360
- encounteredOneOfMyMessages = true
361
- message.myLatestInConversation = true
362
- } else {
363
- message.myLatestInConversation = false
364
- }
365
- message.lastInGroup = lastInGroup
366
- message.renderAuthor = renderAuthor
367
- message.threadPosition = null
368
- message.nextRendersAuthor = nextMessage?.renderAuthor
369
- message.isReplyShadowMessage = false
370
- message.nextIsReplyShadowMessage = nextIsReplyShadowMessage
371
-
372
- if (!inReplyScreen && inThread) {
373
- message.prevIsMyReply = prevMessage?.mine
374
- message.nextIsMyReply = nextMessage?.mine
375
-
376
- const firstInThread = threadRoot
377
- const lastInThread = nextMessageDifferentThread || nextMessageIsDateSeparator
378
-
379
- if (firstInThread && lastInThread)
380
- message.threadPosition = null // ensures we don't render a connector for root replies that aren't immediately followed up a reply
381
- else if (firstInThread) message.threadPosition = 'first'
382
- else if (lastInThread) message.threadPosition = 'last'
383
- else message.threadPosition = 'center'
384
- }
385
-
386
- enrichedMessages.push(message)
387
-
388
- if (insertReplyShadowMessage) {
389
- enrichedMessages.push({
390
- type: 'ReplyShadowMessage',
391
- id: `${message.id}-${message.replyRootId}`,
392
- messageId: message.replyRootId!,
393
- isReplyShadowMessage: true,
394
- nextRendersAuthor: message?.renderAuthor,
395
- })
396
- }
397
-
398
- if (!prevMessage || date !== dayjs(prevMessage.createdAt).format('YYYY-MM-DD')) {
399
- enrichedMessages.push({ type: 'DateSeparator', id: `day-divider-${message.id}`, date })
400
- }
401
- })
402
-
403
- return enrichedMessages
404
- }
405
288
  interface ConversationScreenTitleProps extends HeaderTitleProps {
406
289
  conversation_id: number
407
290
  badge?: ConversationBadgeResource
@@ -0,0 +1,105 @@
1
+ import { ApiCollection, MessageResource } from '../../types'
2
+ import {
3
+ anchoredSeedPageParams,
4
+ newerPageParam,
5
+ olderPageParam,
6
+ sortAndFilterMessages,
7
+ } from '../conversation_messages'
8
+
9
+ const message = (id: string, overrides: Partial<MessageResource> = {}): MessageResource =>
10
+ ({
11
+ id,
12
+ type: 'Message',
13
+ text: `msg ${id}`,
14
+ attachments: [],
15
+ deletedAt: null,
16
+ replyRootId: null,
17
+ ...overrides,
18
+ }) as MessageResource
19
+
20
+ const page = (
21
+ data: MessageResource[],
22
+ next?: { idLt?: string; idGt?: string }
23
+ ): ApiCollection<MessageResource> => ({
24
+ data,
25
+ links: {},
26
+ meta: { count: data.length, totalCount: data.length, next },
27
+ })
28
+
29
+ describe('anchoredSeedPageParams', () => {
30
+ it('returns one ascending after-page and one descending before-page', () => {
31
+ expect(anchoredSeedPageParams('01ABC')).toEqual([
32
+ { where: { id_gte: '01ABC' }, order: 'asc' },
33
+ { where: { id_lt: '01ABC' }, order: 'desc' },
34
+ ])
35
+ })
36
+ })
37
+
38
+ describe('olderPageParam', () => {
39
+ it('returns id_lt cursor when meta.next.idLt is present', () => {
40
+ expect(olderPageParam(page([], { idLt: '01XYZ' }))).toEqual({
41
+ where: { id_lt: '01XYZ' },
42
+ order: 'desc',
43
+ })
44
+ })
45
+
46
+ it('returns undefined when meta.next.idLt is missing', () => {
47
+ expect(olderPageParam(page([], {}))).toBeUndefined()
48
+ expect(olderPageParam(page([], { idGt: '01XYZ' }))).toBeUndefined()
49
+ })
50
+ })
51
+
52
+ describe('newerPageParam', () => {
53
+ it('returns id_gt cursor when meta.next.idGt is present', () => {
54
+ expect(newerPageParam(page([], { idGt: '01XYZ' }))).toEqual({
55
+ where: { id_gt: '01XYZ' },
56
+ order: 'asc',
57
+ })
58
+ })
59
+
60
+ it('returns undefined when meta.next.idGt is missing', () => {
61
+ expect(newerPageParam(page([], {}))).toBeUndefined()
62
+ expect(newerPageParam(page([], { idLt: '01XYZ' }))).toBeUndefined()
63
+ })
64
+ })
65
+
66
+ describe('sortAndFilterMessages', () => {
67
+ it('flattens pages and sorts descending by id (newest first)', () => {
68
+ const pages = [page([message('01B'), message('01A')]), page([message('01D'), message('01C')])]
69
+
70
+ expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01D', '01C', '01B', '01A'])
71
+ })
72
+
73
+ it('drops empty messages (no text, no attachments)', () => {
74
+ const pages = [page([message('01A'), message('01B', { text: '' })])]
75
+
76
+ expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01A'])
77
+ })
78
+
79
+ it('drops deleted messages outside reply threads', () => {
80
+ const pages = [page([message('01A'), message('01B', { deletedAt: '2026-01-01T00:00:00Z' })])]
81
+
82
+ expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01A'])
83
+ })
84
+
85
+ it('keeps deleted reply-thread messages so threads do not break', () => {
86
+ const pages = [
87
+ page([
88
+ message('01A'),
89
+ message('01B', { deletedAt: '2026-01-01T00:00:00Z', replyRootId: '01A' }),
90
+ ]),
91
+ ]
92
+
93
+ expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01B', '01A'])
94
+ })
95
+
96
+ it('keeps messages with attachments even when text is empty', () => {
97
+ const pages = [
98
+ page([
99
+ message('01A', { text: '', attachments: [{ id: '1' }] as MessageResource['attachments'] }),
100
+ ]),
101
+ ]
102
+
103
+ expect(sortAndFilterMessages(pages).map(m => m.id)).toEqual(['01A'])
104
+ })
105
+ })
@@ -0,0 +1,143 @@
1
+ import type { MessageResource } from '../../types/resources/message'
2
+ import type { PersonResource } from '../../types/resources/person'
3
+ import { groupMessages } from '../group_messages'
4
+
5
+ const author = { id: 1, type: 'Person', name: 'A', avatar: null } as unknown as PersonResource
6
+ const otherAuthor = {
7
+ id: 2,
8
+ type: 'Person',
9
+ name: 'B',
10
+ avatar: null,
11
+ } as unknown as PersonResource
12
+
13
+ const message = (id: string, overrides: Partial<MessageResource> = {}): MessageResource =>
14
+ ({
15
+ type: 'Message',
16
+ id,
17
+ text: `msg ${id}`,
18
+ html: `<p>msg ${id}</p>`,
19
+ createdAt: '2026-01-01T00:00:00Z',
20
+ deletedAt: null,
21
+ textEditedAt: null,
22
+ mine: false,
23
+ attachments: [],
24
+ author,
25
+ reactionCounts: [],
26
+ replyCount: 0,
27
+ replyRootId: null,
28
+ messageType: 'message',
29
+ personIdsForSystemEvent: null,
30
+ systemTextParts: null,
31
+ ...overrides,
32
+ }) as MessageResource
33
+
34
+ const findEnriched = (items: ReturnType<typeof groupMessages>, id: string) =>
35
+ items.find(item => 'id' in item && item.id === id && !('type' in item && item.type !== 'Message'))
36
+
37
+ describe('groupMessages — immutability', () => {
38
+ it('does not mutate the input messages', () => {
39
+ const input = [message('02'), message('01')]
40
+ const snapshot = JSON.parse(JSON.stringify(input))
41
+
42
+ groupMessages({ ms: input })
43
+
44
+ expect(input).toEqual(snapshot)
45
+ })
46
+ })
47
+
48
+ describe('groupMessages — grouping', () => {
49
+ it('breaks groups across a >5min gap (lastInGroup flips on the older message)', () => {
50
+ const messages = [
51
+ message('02', { createdAt: '2026-01-01T00:06:00Z' }),
52
+ message('01', { createdAt: '2026-01-01T00:00:00Z' }),
53
+ ]
54
+
55
+ const enriched = groupMessages({ ms: messages })
56
+
57
+ expect((findEnriched(enriched, '01') as MessageResource).lastInGroup).toBe(true)
58
+ })
59
+
60
+ it('breaks groups when authors differ (renderAuthor flips on the newer message)', () => {
61
+ const messages = [message('02', { author }), message('01', { author: otherAuthor })]
62
+
63
+ const enriched = groupMessages({ ms: messages })
64
+
65
+ expect((findEnriched(enriched, '02') as MessageResource).renderAuthor).toBe(true)
66
+ })
67
+
68
+ it('marks the first own message as myLatestInConversation and not later ones', () => {
69
+ const messages = [
70
+ message('03', { mine: true, createdAt: '2026-01-01T00:02:00Z' }),
71
+ message('02', { mine: true, createdAt: '2026-01-01T00:01:00Z' }),
72
+ message('01', { mine: false, createdAt: '2026-01-01T00:00:00Z' }),
73
+ ]
74
+
75
+ const enriched = groupMessages({ ms: messages })
76
+
77
+ expect((findEnriched(enriched, '03') as MessageResource).myLatestInConversation).toBe(true)
78
+ expect((findEnriched(enriched, '02') as MessageResource).myLatestInConversation).toBe(false)
79
+ })
80
+ })
81
+
82
+ describe('groupMessages — render order at thread + date boundaries', () => {
83
+ it('reply shadow precedes date separator so the date renders above', () => {
84
+ const messages = [
85
+ message('02', { createdAt: '2026-01-02T12:00:00Z', replyRootId: 'rootB' }),
86
+ message('01', { createdAt: '2026-01-01T12:00:00Z', replyRootId: 'rootA' }),
87
+ ]
88
+
89
+ const enriched = groupMessages({ ms: messages })
90
+
91
+ const shadowIdx = enriched.findIndex(
92
+ item => 'type' in item && item.type === 'ReplyShadowMessage' && item.id === '02-rootB'
93
+ )
94
+ const dateSepIdx = enriched.findIndex(
95
+ item => 'type' in item && item.type === 'DateSeparator' && item.id === 'day-divider-02'
96
+ )
97
+ expect(shadowIdx).toBeGreaterThan(-1)
98
+ expect(dateSepIdx).toBeGreaterThan(shadowIdx)
99
+ })
100
+
101
+ it('skips reply-shadow insertion when inReplyScreen is false but threadPosition still resolves', () => {
102
+ const messages = [
103
+ message('02', { replyRootId: 'rootA', createdAt: '2026-01-01T00:01:00Z' }),
104
+ message('01', { id: 'rootA', replyRootId: 'rootA', createdAt: '2026-01-01T00:00:00Z' }),
105
+ ]
106
+
107
+ const enriched = groupMessages({ ms: messages })
108
+
109
+ expect((findEnriched(enriched, '02') as MessageResource).threadPosition).toBe('last')
110
+ expect((findEnriched(enriched, 'rootA') as MessageResource).threadPosition).toBe('first')
111
+ })
112
+ })
113
+
114
+ describe('groupMessages — nextRendersAuthor mirrors the newer enriched neighbor', () => {
115
+ it('propagates the newer neighbor’s computed renderAuthor across an author-change boundary', () => {
116
+ const messages = [message('02', { author: otherAuthor }), message('01', { author })]
117
+
118
+ const enriched = groupMessages({ ms: messages })
119
+
120
+ const newer = findEnriched(enriched, '02') as MessageResource
121
+ const older = findEnriched(enriched, '01') as MessageResource
122
+ expect(newer.renderAuthor).toBe(true)
123
+ expect(older.nextRendersAuthor).toBe(true)
124
+ })
125
+ })
126
+
127
+ describe('groupMessages — system messages', () => {
128
+ it('flags lastInGroup true and renderAuthor false on system messages', () => {
129
+ const messages = [
130
+ message('sys', {
131
+ messageType: 'user_joined',
132
+ systemTextParts: { names: ['A'], overflowCount: 0, action: 'joined' },
133
+ personIdsForSystemEvent: [1],
134
+ }),
135
+ ]
136
+
137
+ const enriched = groupMessages({ ms: messages })
138
+
139
+ const sys = findEnriched(enriched, 'sys') as MessageResource
140
+ expect(sys.lastInGroup).toBe(true)
141
+ expect(sys.renderAuthor).toBe(false)
142
+ })
143
+ })
@@ -0,0 +1,37 @@
1
+ import { ApiCollection, MessageResource } from '../types'
2
+ import { RequestData } from './client'
3
+
4
+ export type MessagesPageParam = Partial<RequestData> & {
5
+ order?: 'asc' | 'desc'
6
+ }
7
+
8
+ export const anchoredSeedPageParams = (anchor: string): MessagesPageParam[] => [
9
+ { where: { id_gte: anchor }, order: 'asc' },
10
+ { where: { id_lt: anchor }, order: 'desc' },
11
+ ]
12
+
13
+ export const olderPageParam = (
14
+ page: ApiCollection<MessageResource>
15
+ ): MessagesPageParam | undefined => {
16
+ const idLt = page.meta?.next?.idLt
17
+ if (!idLt) return undefined
18
+ return { where: { id_lt: idLt }, order: 'desc' }
19
+ }
20
+
21
+ export const newerPageParam = (
22
+ page: ApiCollection<MessageResource>
23
+ ): MessagesPageParam | undefined => {
24
+ const idGt = page.meta?.next?.idGt
25
+ if (!idGt) return undefined
26
+ return { where: { id_gt: idGt }, order: 'asc' }
27
+ }
28
+
29
+ export const sortAndFilterMessages = (pages: ApiCollection<MessageResource>[]): MessageResource[] =>
30
+ pages
31
+ .flatMap(page => page.data)
32
+ .filter(
33
+ message =>
34
+ (!message.deletedAt || message.replyRootId) &&
35
+ (message.attachments?.length || message.text?.length)
36
+ )
37
+ .sort((a, b) => -a.id.localeCompare(b.id))