@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
@@ -0,0 +1,177 @@
1
+ import type { MessageResource } from '../types/resources/message'
2
+ import dayjs from './dayjs'
3
+ import { isSystemMessage } from './system_messages'
4
+
5
+ const FIVE_MINUTES_MS = 5 * 60 * 1000
6
+
7
+ export type DateSeparator = { type: 'DateSeparator'; id: string; date: string }
8
+
9
+ export type ReplyShadowMessage = {
10
+ type: 'ReplyShadowMessage'
11
+ id: string
12
+ messageId: string
13
+ isReplyShadowMessage: boolean
14
+ nextRendersAuthor: boolean
15
+ }
16
+
17
+ export type EnrichedMessage = MessageResource | DateSeparator | ReplyShadowMessage
18
+
19
+ interface GroupMessagesProps {
20
+ ms: MessageResource[]
21
+ inReplyScreen?: boolean
22
+ }
23
+
24
+ export function groupMessages({ ms, inReplyScreen }: GroupMessagesProps): EnrichedMessage[] {
25
+ const items: EnrichedMessage[] = []
26
+ let myLatestSeen = false
27
+ let nextNeighborEnriched: MessageResource | undefined
28
+
29
+ ms.forEach((message, i) => {
30
+ const { prev } = neighborsOf(ms, i)
31
+ const next = nextNeighborEnriched
32
+
33
+ if (isSystemMessage(message)) {
34
+ const enriched = enrichSystemMessage(message, next)
35
+ items.push(enriched)
36
+ if (datesDifferBetween(message, prev)) items.push(dateSeparator(message))
37
+ nextNeighborEnriched = enriched
38
+ return
39
+ }
40
+
41
+ const isMyLatest = !myLatestSeen && message.mine
42
+ if (isMyLatest) myLatestSeen = true
43
+
44
+ const enriched = enrichRegularMessage(message, prev, next, isMyLatest, !!inReplyScreen)
45
+ items.push(enriched)
46
+
47
+ const shadow = replyShadowFor(enriched, prev)
48
+ if (shadow) items.push(shadow)
49
+
50
+ if (!prev || datesDifferBetween(message, prev)) {
51
+ items.push(dateSeparator(message))
52
+ }
53
+
54
+ nextNeighborEnriched = enriched
55
+ })
56
+
57
+ return items
58
+ }
59
+
60
+ function neighborsOf<T>(arr: T[], i: number): { prev: T | undefined; next: T | undefined } {
61
+ return { prev: arr[i + 1], next: arr[i - 1] }
62
+ }
63
+
64
+ function enrichSystemMessage(
65
+ message: MessageResource,
66
+ next: MessageResource | undefined
67
+ ): MessageResource {
68
+ return {
69
+ ...message,
70
+ myLatestInConversation: false,
71
+ lastInGroup: true,
72
+ renderAuthor: false,
73
+ nextRendersAuthor: next?.renderAuthor,
74
+ isReplyShadowMessage: false,
75
+ nextIsReplyShadowMessage: false,
76
+ threadPosition: null,
77
+ }
78
+ }
79
+
80
+ function enrichRegularMessage(
81
+ message: MessageResource,
82
+ prev: MessageResource | undefined,
83
+ next: MessageResource | undefined,
84
+ isMyLatest: boolean,
85
+ inReplyScreen: boolean
86
+ ): MessageResource {
87
+ const inThread = message.replyRootId !== null
88
+ const showThreadDetails = !inReplyScreen && inThread
89
+
90
+ return {
91
+ ...message,
92
+ myLatestInConversation: isMyLatest,
93
+ lastInGroup: !next || startsNewGroup(next, message),
94
+ renderAuthor: !message.mine && (!prev || startsNewGroup(message, prev)),
95
+ nextRendersAuthor: next?.renderAuthor,
96
+ isReplyShadowMessage: false,
97
+ nextIsReplyShadowMessage: nextIntroducesReplyShadow(next, message),
98
+ threadPosition: showThreadDetails ? threadPositionFor(message, next) : null,
99
+ prevIsMyReply: showThreadDetails ? prev?.mine : undefined,
100
+ nextIsMyReply: showThreadDetails ? next?.mine : undefined,
101
+ }
102
+ }
103
+
104
+ function startsNewGroup(a: MessageResource, b: MessageResource): boolean {
105
+ return (
106
+ a.author?.id !== b.author?.id ||
107
+ differsByMoreThan5Min(a, b) ||
108
+ a.replyRootId !== b.replyRootId ||
109
+ datesDifferBetween(a, b)
110
+ )
111
+ }
112
+
113
+ function differsByMoreThan5Min(a: MessageResource, b: MessageResource): boolean {
114
+ return Math.abs(toMillis(a.createdAt) - toMillis(b.createdAt)) > FIVE_MINUTES_MS
115
+ }
116
+
117
+ function toMillis(iso: string): number {
118
+ return new Date(iso).getTime()
119
+ }
120
+
121
+ function datesDifferBetween(a: MessageResource, b: MessageResource | undefined): boolean {
122
+ if (!b) return false
123
+ return dateKey(a) !== dateKey(b)
124
+ }
125
+
126
+ function dateKey(message: MessageResource): string {
127
+ return dayjs(message.createdAt).format('YYYY-MM-DD')
128
+ }
129
+
130
+ function dateSeparator(message: MessageResource): DateSeparator {
131
+ return { type: 'DateSeparator', id: `day-divider-${message.id}`, date: dateKey(message) }
132
+ }
133
+
134
+ function threadPositionFor(
135
+ message: MessageResource,
136
+ next: MessageResource | undefined
137
+ ): 'first' | 'center' | 'last' | null {
138
+ const isThreadRoot = message.replyRootId === message.id
139
+ const isLast =
140
+ !next || next.replyRootId !== message.replyRootId || datesDifferBetween(next, message)
141
+
142
+ if (isThreadRoot && isLast) return null
143
+ if (isThreadRoot) return 'first'
144
+ if (isLast) return 'last'
145
+ return 'center'
146
+ }
147
+
148
+ function nextIntroducesReplyShadow(
149
+ next: MessageResource | undefined,
150
+ current: MessageResource
151
+ ): boolean {
152
+ if (!next) return false
153
+ const nextInThread = next.replyRootId !== null
154
+ const nextIsThreadRoot = next.replyRootId === next.id
155
+ const differentThread = next.replyRootId !== current.replyRootId
156
+ return nextInThread && !nextIsThreadRoot && (differentThread || datesDifferBetween(next, current))
157
+ }
158
+
159
+ function replyShadowFor(
160
+ message: MessageResource,
161
+ prev: MessageResource | undefined
162
+ ): ReplyShadowMessage | undefined {
163
+ if (!message.replyRootId) return undefined
164
+ if (message.replyRootId === message.id) return undefined
165
+
166
+ const enteringNewThread =
167
+ !prev || prev.replyRootId !== message.replyRootId || datesDifferBetween(prev, message)
168
+ if (!enteringNewThread) return undefined
169
+
170
+ return {
171
+ type: 'ReplyShadowMessage',
172
+ id: `${message.id}-${message.replyRootId}`,
173
+ messageId: message.replyRootId,
174
+ isReplyShadowMessage: true,
175
+ nextRendersAuthor: message.renderAuthor ?? false,
176
+ }
177
+ }
@@ -1,55 +0,0 @@
1
- import { renderHook } from '@testing-library/react-hooks'
2
- import { useConversationMessages } from '../../hooks/use_conversation_messages'
3
- import * as useSuspenseApi from '../../hooks/use_suspense_api'
4
-
5
- const mockMessages = [
6
- {
7
- id: '1',
8
- text: 'Hello',
9
- deletedAt: null,
10
- attachments: [],
11
- },
12
- {
13
- id: '2',
14
- text: '',
15
- deletedAt: null,
16
- attachments: [{ id: 'a1' }],
17
- },
18
- {
19
- id: '3',
20
- text: '',
21
- deletedAt: '2024-01-01',
22
- attachments: [],
23
- },
24
- {
25
- id: '4',
26
- text: '',
27
- deletedAt: null,
28
- attachments: [],
29
- },
30
- ]
31
-
32
- describe('useConversationMessages', () => {
33
- beforeEach(() => {
34
- jest.spyOn(useSuspenseApi, 'useSuspensePaginator').mockReturnValue({
35
- data: mockMessages,
36
- refetch: jest.fn(),
37
- isRefetching: false,
38
- fetchNextPage: jest.fn(),
39
- } as any)
40
- })
41
-
42
- afterEach(() => {
43
- jest.restoreAllMocks()
44
- })
45
-
46
- it('filters out empty or deleted messages and sorts by id descending', () => {
47
- const { result } = renderHook(() => useConversationMessages({ conversation_id: 123 }))
48
- expect(result.current.messages).toEqual([
49
- mockMessages[1], // id: '2'
50
- mockMessages[0], // id: '1'
51
- ])
52
- // id: '3' is deleted and filtered out
53
- // id: '4' is filtered out because it has no text or attachments
54
- })
55
- })