@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.
- package/build/contexts/conversation_context.d.ts +6 -1
- package/build/contexts/conversation_context.d.ts.map +1 -1
- package/build/contexts/conversation_context.js +13 -3
- package/build/contexts/conversation_context.js.map +1 -1
- package/build/hooks/use_conversation_messages.d.ts +13 -6
- package/build/hooks/use_conversation_messages.d.ts.map +1 -1
- package/build/hooks/use_conversation_messages.js +56 -7
- package/build/hooks/use_conversation_messages.js.map +1 -1
- package/build/hooks/use_features.d.ts +1 -0
- package/build/hooks/use_features.d.ts.map +1 -1
- package/build/hooks/use_features.js +1 -0
- package/build/hooks/use_features.js.map +1 -1
- package/build/hooks/use_suspense_api.d.ts +1 -0
- package/build/hooks/use_suspense_api.d.ts.map +1 -1
- package/build/hooks/use_suspense_api.js +1 -1
- package/build/hooks/use_suspense_api.js.map +1 -1
- package/build/screens/conversation_screen.d.ts +1 -19
- package/build/screens/conversation_screen.d.ts.map +1 -1
- package/build/screens/conversation_screen.js +11 -100
- package/build/screens/conversation_screen.js.map +1 -1
- package/build/utils/conversation_messages.d.ts +10 -0
- package/build/utils/conversation_messages.d.ts.map +1 -0
- package/build/utils/conversation_messages.js +22 -0
- package/build/utils/conversation_messages.js.map +1 -0
- package/build/utils/group_messages.d.ts +21 -0
- package/build/utils/group_messages.d.ts.map +1 -0
- package/build/utils/group_messages.js +123 -0
- package/build/utils/group_messages.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/hooks/use_conversation_messages.test.tsx +109 -0
- package/src/contexts/conversation_context.tsx +28 -2
- package/src/hooks/use_conversation_messages.ts +105 -21
- package/src/hooks/use_features.ts +1 -0
- package/src/hooks/use_suspense_api.ts +1 -1
- package/src/screens/conversation_screen.tsx +14 -131
- package/src/utils/__tests__/conversation_messages.test.ts +105 -0
- package/src/utils/__tests__/group_messages.test.ts +143 -0
- package/src/utils/conversation_messages.ts +37 -0
- package/src/utils/group_messages.ts +177 -0
- 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 {
|
|
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 {
|
|
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 }:
|
|
8
|
-
opts?:
|
|
34
|
+
{ conversation_id, reply_root_id }: Args,
|
|
35
|
+
opts?: ConversationMessagesOptions
|
|
9
36
|
) => {
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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,
|
|
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={() =>
|
|
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))
|