@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
|
@@ -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
|
-
})
|