@planningcenter/chat-react-native 3.35.0-rc.2 → 3.35.0-rc.4

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 (112) hide show
  1. package/README.md +1 -1
  2. package/build/components/conversation/jump_to_bottom_button.d.ts +2 -1
  3. package/build/components/conversation/jump_to_bottom_button.d.ts.map +1 -1
  4. package/build/components/conversation/jump_to_bottom_button.js +39 -7
  5. package/build/components/conversation/jump_to_bottom_button.js.map +1 -1
  6. package/build/components/conversation/reply_shadow_message.d.ts +1 -2
  7. package/build/components/conversation/reply_shadow_message.d.ts.map +1 -1
  8. package/build/components/conversation/reply_shadow_message.js.map +1 -1
  9. package/build/components/conversation/unread_divider.d.ts +6 -0
  10. package/build/components/conversation/unread_divider.d.ts.map +1 -0
  11. package/build/components/conversation/unread_divider.js +59 -0
  12. package/build/components/conversation/unread_divider.js.map +1 -0
  13. package/build/contexts/conversation_context.d.ts +2 -0
  14. package/build/contexts/conversation_context.d.ts.map +1 -1
  15. package/build/contexts/conversation_context.js +13 -5
  16. package/build/contexts/conversation_context.js.map +1 -1
  17. package/build/hooks/use_conversation_messages.d.ts +2 -0
  18. package/build/hooks/use_conversation_messages.d.ts.map +1 -1
  19. package/build/hooks/use_conversation_messages.js +9 -5
  20. package/build/hooks/use_conversation_messages.js.map +1 -1
  21. package/build/hooks/use_conversation_messages_jolt_events.d.ts.map +1 -1
  22. package/build/hooks/use_conversation_messages_jolt_events.js +4 -4
  23. package/build/hooks/use_conversation_messages_jolt_events.js.map +1 -1
  24. package/build/hooks/use_conversations_actions.d.ts +5 -0
  25. package/build/hooks/use_conversations_actions.d.ts.map +1 -1
  26. package/build/hooks/use_conversations_actions.js +12 -0
  27. package/build/hooks/use_conversations_actions.js.map +1 -1
  28. package/build/hooks/use_flat_list_viewability.d.ts +20 -0
  29. package/build/hooks/use_flat_list_viewability.d.ts.map +1 -0
  30. package/build/hooks/use_flat_list_viewability.js +30 -0
  31. package/build/hooks/use_flat_list_viewability.js.map +1 -0
  32. package/build/hooks/use_jump_to_bottom_action.d.ts +9 -0
  33. package/build/hooks/use_jump_to_bottom_action.d.ts.map +1 -0
  34. package/build/hooks/use_jump_to_bottom_action.js +62 -0
  35. package/build/hooks/use_jump_to_bottom_action.js.map +1 -0
  36. package/build/hooks/use_jump_to_unread_anchor.d.ts +20 -0
  37. package/build/hooks/use_jump_to_unread_anchor.d.ts.map +1 -0
  38. package/build/hooks/use_jump_to_unread_anchor.js +53 -0
  39. package/build/hooks/use_jump_to_unread_anchor.js.map +1 -0
  40. package/build/hooks/use_jump_to_unread_gates.d.ts +5 -0
  41. package/build/hooks/use_jump_to_unread_gates.d.ts.map +1 -0
  42. package/build/hooks/use_jump_to_unread_gates.js +10 -0
  43. package/build/hooks/use_jump_to_unread_gates.js.map +1 -0
  44. package/build/hooks/use_mark_latest_message_read.d.ts +1 -1
  45. package/build/hooks/use_mark_latest_message_read.d.ts.map +1 -1
  46. package/build/hooks/use_mark_latest_message_read.js +17 -1
  47. package/build/hooks/use_mark_latest_message_read.js.map +1 -1
  48. package/build/hooks/use_scroll_tracking.d.ts +13 -0
  49. package/build/hooks/use_scroll_tracking.d.ts.map +1 -0
  50. package/build/hooks/use_scroll_tracking.js +45 -0
  51. package/build/hooks/use_scroll_tracking.js.map +1 -0
  52. package/build/hooks/use_track_highest_seen_message.d.ts +4 -0
  53. package/build/hooks/use_track_highest_seen_message.d.ts.map +1 -0
  54. package/build/hooks/use_track_highest_seen_message.js +35 -0
  55. package/build/hooks/use_track_highest_seen_message.js.map +1 -0
  56. package/build/navigation/index.d.ts.map +1 -1
  57. package/build/screens/conversation_screen.d.ts +0 -19
  58. package/build/screens/conversation_screen.d.ts.map +1 -1
  59. package/build/screens/conversation_screen.js +87 -139
  60. package/build/screens/conversation_screen.js.map +1 -1
  61. package/build/utils/cache/messages_cache.d.ts +1 -0
  62. package/build/utils/cache/messages_cache.d.ts.map +1 -1
  63. package/build/utils/cache/messages_cache.js +4 -0
  64. package/build/utils/cache/messages_cache.js.map +1 -1
  65. package/build/utils/group_messages.d.ts +28 -0
  66. package/build/utils/group_messages.d.ts.map +1 -0
  67. package/build/utils/group_messages.js +142 -0
  68. package/build/utils/group_messages.js.map +1 -0
  69. package/build/utils/highest_seen_tracker.d.ts +12 -0
  70. package/build/utils/highest_seen_tracker.d.ts.map +1 -0
  71. package/build/utils/highest_seen_tracker.js +37 -0
  72. package/build/utils/highest_seen_tracker.js.map +1 -0
  73. package/build/utils/message_viewability.d.ts +24 -0
  74. package/build/utils/message_viewability.d.ts.map +1 -0
  75. package/build/utils/message_viewability.js +29 -0
  76. package/build/utils/message_viewability.js.map +1 -0
  77. package/build/utils/unread_divider_helpers.d.ts +18 -0
  78. package/build/utils/unread_divider_helpers.d.ts.map +1 -0
  79. package/build/utils/unread_divider_helpers.js +13 -0
  80. package/build/utils/unread_divider_helpers.js.map +1 -0
  81. package/package.json +10 -4
  82. package/src/__tests__/contexts/session_context.tsx +1 -1
  83. package/src/__tests__/hooks/use_async_storage.test.tsx +1 -1
  84. package/src/__tests__/hooks/use_attachment_uploader.test.tsx +1 -1
  85. package/src/__tests__/hooks/use_chat_configuration.test.tsx +1 -1
  86. package/src/__tests__/hooks/use_conversation_messages.test.tsx +1 -1
  87. package/src/__tests__/hooks/use_mark_latest_message_read.test.tsx +154 -0
  88. package/src/__tests__/utils/cache/messages_cache.test.ts +54 -0
  89. package/src/components/conversation/jump_to_bottom_button.tsx +57 -8
  90. package/src/components/conversation/reply_shadow_message.tsx +4 -2
  91. package/src/components/conversation/unread_divider.tsx +90 -0
  92. package/src/contexts/conversation_context.tsx +15 -13
  93. package/src/hooks/use_conversation_messages.ts +19 -3
  94. package/src/hooks/use_conversation_messages_jolt_events.ts +4 -3
  95. package/src/hooks/use_conversations_actions.ts +15 -0
  96. package/src/hooks/use_flat_list_viewability.ts +50 -0
  97. package/src/hooks/use_jump_to_bottom_action.ts +75 -0
  98. package/src/hooks/use_jump_to_unread_anchor.ts +68 -0
  99. package/src/hooks/use_jump_to_unread_gates.ts +10 -0
  100. package/src/hooks/use_mark_latest_message_read.ts +16 -2
  101. package/src/hooks/use_scroll_tracking.ts +64 -0
  102. package/src/hooks/use_track_highest_seen_message.ts +43 -0
  103. package/src/screens/conversation_screen.tsx +173 -197
  104. package/src/utils/__tests__/group_messages.test.ts +214 -0
  105. package/src/utils/__tests__/highest_seen_tracker.test.ts +82 -0
  106. package/src/utils/__tests__/message_viewability.test.ts +168 -0
  107. package/src/utils/__tests__/unread_divider_helpers.test.ts +85 -0
  108. package/src/utils/cache/messages_cache.ts +5 -0
  109. package/src/utils/group_messages.ts +217 -0
  110. package/src/utils/highest_seen_tracker.ts +42 -0
  111. package/src/utils/message_viewability.ts +49 -0
  112. package/src/utils/unread_divider_helpers.ts +25 -0
@@ -0,0 +1,217 @@
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 const UNREAD_DIVIDER_KEY = 'unread-divider'
8
+
9
+ export type DateSeparator = { type: 'DateSeparator'; id: string; date: string }
10
+
11
+ export type UnreadDividerItem = { type: 'UnreadDivider'; id: typeof UNREAD_DIVIDER_KEY }
12
+
13
+ export type ReplyShadowMessage = {
14
+ type: 'ReplyShadowMessage'
15
+ id: string
16
+ messageId: string
17
+ isReplyShadowMessage: boolean
18
+ nextRendersAuthor: boolean
19
+ }
20
+
21
+ export type EnrichedMessage =
22
+ | MessageResource
23
+ | DateSeparator
24
+ | UnreadDividerItem
25
+ | ReplyShadowMessage
26
+
27
+ interface GroupMessagesProps {
28
+ ms: MessageResource[]
29
+ inReplyScreen?: boolean
30
+ jumpToUnreadActive?: boolean
31
+ initialMessageId?: string | null
32
+ }
33
+
34
+ export function groupMessages({
35
+ ms,
36
+ inReplyScreen,
37
+ jumpToUnreadActive,
38
+ initialMessageId,
39
+ }: GroupMessagesProps): EnrichedMessage[] {
40
+ const items: EnrichedMessage[] = []
41
+ let myLatestSeen = false
42
+ let nextNeighborEnriched: MessageResource | undefined
43
+
44
+ ms.forEach((message, i) => {
45
+ const { prev } = neighborsOf(ms, i)
46
+ const next = nextNeighborEnriched
47
+
48
+ if (isSystemMessage(message)) {
49
+ const enriched = enrichSystemMessage(message, next)
50
+ items.push(enriched)
51
+ if (crossesUnreadBoundary(message, prev, jumpToUnreadActive, initialMessageId)) {
52
+ items.push(unreadDivider())
53
+ }
54
+ if (datesDifferBetween(message, prev)) items.push(dateSeparator(message))
55
+ nextNeighborEnriched = enriched
56
+ return
57
+ }
58
+
59
+ const isMyLatest = !myLatestSeen && message.mine
60
+ if (isMyLatest) myLatestSeen = true
61
+
62
+ const enriched = enrichRegularMessage(message, prev, next, isMyLatest, !!inReplyScreen)
63
+ items.push(enriched)
64
+
65
+ if (crossesUnreadBoundary(message, prev, jumpToUnreadActive, initialMessageId)) {
66
+ items.push(unreadDivider())
67
+ }
68
+
69
+ const shadow = replyShadowFor(enriched, prev)
70
+ if (shadow) items.push(shadow)
71
+
72
+ if (!prev || datesDifferBetween(message, prev)) {
73
+ items.push(dateSeparator(message))
74
+ }
75
+
76
+ nextNeighborEnriched = enriched
77
+ })
78
+
79
+ return items
80
+ }
81
+
82
+ function crossesUnreadBoundary(
83
+ message: MessageResource,
84
+ prev: MessageResource | undefined,
85
+ jumpToUnreadActive: boolean | undefined,
86
+ initialMessageId: string | null | undefined
87
+ ): boolean {
88
+ if (!jumpToUnreadActive) return false
89
+ if (!initialMessageId) return false
90
+ if (!prev) return false
91
+ return (
92
+ prev.id.localeCompare(initialMessageId) <= 0 && message.id.localeCompare(initialMessageId) > 0
93
+ )
94
+ }
95
+
96
+ function unreadDivider(): UnreadDividerItem {
97
+ return { type: 'UnreadDivider', id: UNREAD_DIVIDER_KEY }
98
+ }
99
+
100
+ function neighborsOf<T>(arr: T[], i: number): { prev: T | undefined; next: T | undefined } {
101
+ return { prev: arr[i + 1], next: arr[i - 1] }
102
+ }
103
+
104
+ function enrichSystemMessage(
105
+ message: MessageResource,
106
+ next: MessageResource | undefined
107
+ ): MessageResource {
108
+ return {
109
+ ...message,
110
+ myLatestInConversation: false,
111
+ lastInGroup: true,
112
+ renderAuthor: false,
113
+ nextRendersAuthor: next?.renderAuthor,
114
+ isReplyShadowMessage: false,
115
+ nextIsReplyShadowMessage: false,
116
+ threadPosition: null,
117
+ }
118
+ }
119
+
120
+ function enrichRegularMessage(
121
+ message: MessageResource,
122
+ prev: MessageResource | undefined,
123
+ next: MessageResource | undefined,
124
+ isMyLatest: boolean,
125
+ inReplyScreen: boolean
126
+ ): MessageResource {
127
+ const inThread = message.replyRootId !== null
128
+ const showThreadDetails = !inReplyScreen && inThread
129
+
130
+ return {
131
+ ...message,
132
+ myLatestInConversation: isMyLatest,
133
+ lastInGroup: !next || startsNewGroup(next, message),
134
+ renderAuthor: !message.mine && (!prev || startsNewGroup(message, prev)),
135
+ nextRendersAuthor: next?.renderAuthor,
136
+ isReplyShadowMessage: false,
137
+ nextIsReplyShadowMessage: nextIntroducesReplyShadow(next, message),
138
+ threadPosition: showThreadDetails ? threadPositionFor(message, next) : null,
139
+ prevIsMyReply: showThreadDetails ? prev?.mine : undefined,
140
+ nextIsMyReply: showThreadDetails ? next?.mine : undefined,
141
+ }
142
+ }
143
+
144
+ function startsNewGroup(a: MessageResource, b: MessageResource): boolean {
145
+ return (
146
+ a.author?.id !== b.author?.id ||
147
+ differsByMoreThan5Min(a, b) ||
148
+ a.replyRootId !== b.replyRootId ||
149
+ datesDifferBetween(a, b)
150
+ )
151
+ }
152
+
153
+ function differsByMoreThan5Min(a: MessageResource, b: MessageResource): boolean {
154
+ return Math.abs(toMillis(a.createdAt) - toMillis(b.createdAt)) > FIVE_MINUTES_MS
155
+ }
156
+
157
+ function toMillis(iso: string): number {
158
+ return new Date(iso).getTime()
159
+ }
160
+
161
+ function datesDifferBetween(a: MessageResource, b: MessageResource | undefined): boolean {
162
+ if (!b) return false
163
+ return dateKey(a) !== dateKey(b)
164
+ }
165
+
166
+ function dateKey(message: MessageResource): string {
167
+ return dayjs(message.createdAt).format('YYYY-MM-DD')
168
+ }
169
+
170
+ function dateSeparator(message: MessageResource): DateSeparator {
171
+ return { type: 'DateSeparator', id: `day-divider-${message.id}`, date: dateKey(message) }
172
+ }
173
+
174
+ function threadPositionFor(
175
+ message: MessageResource,
176
+ next: MessageResource | undefined
177
+ ): 'first' | 'center' | 'last' | null {
178
+ const isThreadRoot = message.replyRootId === message.id
179
+ const isLast =
180
+ !next || next.replyRootId !== message.replyRootId || datesDifferBetween(next, message)
181
+
182
+ if (isThreadRoot && isLast) return null
183
+ if (isThreadRoot) return 'first'
184
+ if (isLast) return 'last'
185
+ return 'center'
186
+ }
187
+
188
+ function nextIntroducesReplyShadow(
189
+ next: MessageResource | undefined,
190
+ current: MessageResource
191
+ ): boolean {
192
+ if (!next) return false
193
+ const nextInThread = next.replyRootId !== null
194
+ const nextIsThreadRoot = next.replyRootId === next.id
195
+ const differentThread = next.replyRootId !== current.replyRootId
196
+ return nextInThread && !nextIsThreadRoot && (differentThread || datesDifferBetween(next, current))
197
+ }
198
+
199
+ function replyShadowFor(
200
+ message: MessageResource,
201
+ prev: MessageResource | undefined
202
+ ): ReplyShadowMessage | undefined {
203
+ if (!message.replyRootId) return undefined
204
+ if (message.replyRootId === message.id) return undefined
205
+
206
+ const enteringNewThread =
207
+ !prev || prev.replyRootId !== message.replyRootId || datesDifferBetween(prev, message)
208
+ if (!enteringNewThread) return undefined
209
+
210
+ return {
211
+ type: 'ReplyShadowMessage',
212
+ id: `${message.id}-${message.replyRootId}`,
213
+ messageId: message.replyRootId,
214
+ isReplyShadowMessage: true,
215
+ nextRendersAuthor: message.renderAuthor ?? false,
216
+ }
217
+ }
@@ -0,0 +1,42 @@
1
+ export const FLUSH_DELAY_MS = 2000
2
+
3
+ type SendFn = (args: { conversationId: number; sortKey: string }) => void
4
+
5
+ export function makeHighestSeenTracker(
6
+ conversationId: number,
7
+ send: SendFn,
8
+ flushDelayMs: number = FLUSH_DELAY_MS
9
+ ) {
10
+ let highest: string | null = null
11
+ let lastSent: string | null = null
12
+ let timer: ReturnType<typeof setTimeout> | null = null
13
+
14
+ const fire = () => {
15
+ timer = null
16
+ if (!highest || highest === lastSent) return
17
+ lastSent = highest
18
+ send({ conversationId, sortKey: highest })
19
+ }
20
+
21
+ return {
22
+ onSeen(sortKey: string) {
23
+ if (highest && sortKey.localeCompare(highest) <= 0) return
24
+ highest = sortKey
25
+ if (timer) clearTimeout(timer)
26
+ timer = setTimeout(fire, flushDelayMs)
27
+ },
28
+ flushNow() {
29
+ if (timer) {
30
+ clearTimeout(timer)
31
+ timer = null
32
+ }
33
+ fire()
34
+ },
35
+ cancel() {
36
+ if (timer) {
37
+ clearTimeout(timer)
38
+ timer = null
39
+ }
40
+ },
41
+ }
42
+ }
@@ -0,0 +1,49 @@
1
+ import { dividerExitedTowardNewer } from './unread_divider_helpers'
2
+
3
+ export interface ViewableEntry<Item> {
4
+ key: string
5
+ isViewable: boolean
6
+ item: Item
7
+ }
8
+
9
+ export interface ViewabilityEvent<Item> {
10
+ viewableItems: ViewableEntry<Item>[]
11
+ changed: ViewableEntry<Item>[]
12
+ userHasScrolled: boolean
13
+ }
14
+
15
+ export type ViewabilityObserver<Item> = (event: ViewabilityEvent<Item>) => void
16
+
17
+ export function reportViewableMessages<Item extends { id?: string; type?: string }>(
18
+ onMessageSeen: (id: string) => void
19
+ ): ViewabilityObserver<Item> {
20
+ return ({ viewableItems, userHasScrolled }) => {
21
+ if (!userHasScrolled) return
22
+ for (const entry of viewableItems) {
23
+ if (entry.item?.type !== 'Message') continue
24
+ const id = entry.item?.id
25
+ if (typeof id === 'string') onMessageSeen(id)
26
+ }
27
+ }
28
+ }
29
+
30
+ export function detectDividerExitTowardNewer<Item extends { id?: string; type?: string }>({
31
+ dividerKey,
32
+ initialMessageId,
33
+ onExited,
34
+ }: {
35
+ dividerKey: string
36
+ initialMessageId: string | null
37
+ onExited: () => void
38
+ }): ViewabilityObserver<Item> {
39
+ return ({ viewableItems, changed, userHasScrolled }) => {
40
+ if (!userHasScrolled || !initialMessageId) return
41
+ const exited = dividerExitedTowardNewer({
42
+ changed,
43
+ viewableItems,
44
+ dividerKey,
45
+ initialMessageId,
46
+ })
47
+ if (exited) onExited()
48
+ }
49
+ }
@@ -0,0 +1,25 @@
1
+ type ViewableChangeEntry = { key: string; isViewable: boolean }
2
+ type ViewableItem = { item: { id?: string; type?: string } }
3
+
4
+ export function dividerExitedTowardNewer({
5
+ changed,
6
+ viewableItems,
7
+ dividerKey,
8
+ initialMessageId,
9
+ }: {
10
+ changed: ViewableChangeEntry[]
11
+ viewableItems: ViewableItem[]
12
+ dividerKey: string
13
+ initialMessageId: string
14
+ }): boolean {
15
+ const dividerExited = changed.some(c => c.key === dividerKey && !c.isViewable)
16
+ if (!dividerExited) return false
17
+
18
+ const visibleMessageIds = viewableItems
19
+ .filter(v => v.item?.type === 'Message')
20
+ .map(v => v.item?.id)
21
+ .filter((id): id is string => !!id)
22
+ if (visibleMessageIds.length === 0) return false
23
+
24
+ return visibleMessageIds.every(id => id.localeCompare(initialMessageId) > 0)
25
+ }