forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React, {useCallback, useMemo} from 'react'
2import {
3 type GestureResponderEvent,
4 type StyleProp,
5 type TextStyle,
6 View,
7} from 'react-native'
8import Animated, {
9 LayoutAnimationConfig,
10 LinearTransition,
11 ZoomIn,
12 ZoomOut,
13} from 'react-native-reanimated'
14import {
15 AppBskyEmbedRecord,
16 ChatBskyConvoDefs,
17 RichText as RichTextAPI,
18} from '@atproto/api'
19import {type I18n} from '@lingui/core'
20import {msg} from '@lingui/macro'
21import {useLingui} from '@lingui/react'
22
23import {sanitizeDisplayName} from '#/lib/strings/display-names'
24import {useConvoActive} from '#/state/messages/convo'
25import {type ConvoItem} from '#/state/messages/convo/types'
26import {useSession} from '#/state/session'
27import {TimeElapsed} from '#/view/com/util/TimeElapsed'
28import {atoms as a, native, useTheme} from '#/alf'
29import {isOnlyEmoji} from '#/alf/typography'
30import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
31import {InlineLinkText} from '#/components/Link'
32import {RichText} from '#/components/RichText'
33import {Text} from '#/components/Typography'
34import {IS_NATIVE} from '#/env'
35import {DateDivider} from './DateDivider'
36import {MessageItemEmbed} from './MessageItemEmbed'
37import {localDateString} from './util'
38
39let MessageItem = ({
40 item,
41}: {
42 item: ConvoItem & {type: 'message' | 'pending-message'}
43}): React.ReactNode => {
44 const t = useTheme()
45 const {currentAccount} = useSession()
46 const {_} = useLingui()
47 const {convo} = useConvoActive()
48
49 const {message, nextMessage, prevMessage} = item
50 const isPending = item.type === 'pending-message'
51
52 const isFromSelf = message.sender?.did === currentAccount?.did
53
54 const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
55
56 const isNextFromSelf =
57 nextIsMessage && nextMessage.sender?.did === currentAccount?.did
58
59 const isNextFromSameSender = isNextFromSelf === isFromSelf
60
61 const isNewDay = useMemo(() => {
62 if (!prevMessage) return true
63
64 const thisDate = new Date(message.sentAt)
65 const prevDate = new Date(prevMessage.sentAt)
66
67 return localDateString(thisDate) !== localDateString(prevDate)
68 }, [message, prevMessage])
69
70 const isLastMessageOfDay = useMemo(() => {
71 if (!nextMessage || !nextIsMessage) return true
72
73 const thisDate = new Date(message.sentAt)
74 const prevDate = new Date(nextMessage.sentAt)
75
76 return localDateString(thisDate) !== localDateString(prevDate)
77 }, [message.sentAt, nextIsMessage, nextMessage])
78
79 const needsTail = isLastMessageOfDay || !isNextFromSameSender
80
81 const isLastInGroup = useMemo(() => {
82 // if this message is pending, it means the next message is pending too
83 if (isPending && nextMessage) {
84 return false
85 }
86
87 // or, if there's a 5 minute gap between this message and the next
88 if (ChatBskyConvoDefs.isMessageView(nextMessage)) {
89 const thisDate = new Date(message.sentAt)
90 const nextDate = new Date(nextMessage.sentAt)
91
92 const diff = nextDate.getTime() - thisDate.getTime()
93
94 // 5 minutes
95 return diff > 5 * 60 * 1000
96 }
97
98 return true
99 }, [message, nextMessage, isPending])
100
101 const pendingColor = t.palette.primary_200
102
103 const rt = useMemo(() => {
104 return new RichTextAPI({text: message.text, facets: message.facets})
105 }, [message.text, message.facets])
106
107 const appliedReactions = (
108 <LayoutAnimationConfig skipEntering skipExiting>
109 {message.reactions && message.reactions.length > 0 && (
110 <View
111 style={[isFromSelf ? a.align_end : a.align_start, a.px_sm, a.pb_2xs]}>
112 <View
113 style={[
114 a.flex_row,
115 a.gap_2xs,
116 a.py_xs,
117 a.px_xs,
118 a.justify_center,
119 isFromSelf ? a.justify_end : a.justify_start,
120 a.flex_wrap,
121 a.pb_xs,
122 t.atoms.bg_contrast_25,
123 a.border,
124 t.atoms.border_contrast_low,
125 a.rounded_lg,
126 t.atoms.shadow_sm,
127 {
128 // vibe coded number
129 transform: [{translateY: -11}],
130 },
131 ]}>
132 {message.reactions.map((reaction, _i, reactions) => {
133 let label
134 if (reaction.sender.did === currentAccount?.did) {
135 label = _(msg`You reacted ${reaction.value}`)
136 } else {
137 const senderDid = reaction.sender.did
138 const sender = convo.members.find(
139 member => member.did === senderDid,
140 )
141 if (sender) {
142 label = _(
143 msg`${sanitizeDisplayName(
144 sender.displayName || sender.handle,
145 )} reacted ${reaction.value}`,
146 )
147 } else {
148 label = _(msg`Someone reacted ${reaction.value}`)
149 }
150 }
151 return (
152 <Animated.View
153 entering={native(ZoomIn.springify(200).delay(400))}
154 exiting={reactions.length > 1 && native(ZoomOut.delay(200))}
155 layout={native(LinearTransition.delay(300))}
156 key={reaction.sender.did + reaction.value}
157 style={[a.p_2xs]}
158 accessible={true}
159 accessibilityLabel={label}
160 accessibilityHint={_(
161 msg`Double tap or long press the message to add a reaction`,
162 )}>
163 <Text emoji style={[a.text_sm]}>
164 {reaction.value}
165 </Text>
166 </Animated.View>
167 )
168 })}
169 </View>
170 </View>
171 )}
172 </LayoutAnimationConfig>
173 )
174
175 return (
176 <>
177 {isNewDay && <DateDivider date={message.sentAt} />}
178 <View
179 style={[
180 isFromSelf ? a.mr_md : a.ml_md,
181 nextIsMessage && !isNextFromSameSender && a.mb_md,
182 ]}>
183 <ActionsWrapper isFromSelf={isFromSelf} message={message}>
184 {AppBskyEmbedRecord.isView(message.embed) && (
185 <MessageItemEmbed embed={message.embed} />
186 )}
187 {rt.text.length > 0 && (
188 <View
189 style={
190 !isOnlyEmoji(message.text) && [
191 a.py_sm,
192 a.my_2xs,
193 a.rounded_md,
194 {
195 paddingLeft: 14,
196 paddingRight: 14,
197 backgroundColor: isFromSelf
198 ? isPending
199 ? pendingColor
200 : t.palette.primary_500
201 : t.palette.contrast_50,
202 borderRadius: 17,
203 },
204 isFromSelf ? a.self_end : a.self_start,
205 isFromSelf
206 ? {borderBottomRightRadius: needsTail ? 2 : 17}
207 : {borderBottomLeftRadius: needsTail ? 2 : 17},
208 ]
209 }>
210 <RichText
211 value={rt}
212 style={[a.text_md, isFromSelf && {color: t.palette.white}]}
213 interactiveStyle={a.underline}
214 enableTags
215 emojiMultiplier={3}
216 shouldProxyLinks={true}
217 />
218 </View>
219 )}
220
221 {IS_NATIVE && appliedReactions}
222 </ActionsWrapper>
223
224 {!IS_NATIVE && appliedReactions}
225
226 {isLastInGroup && (
227 <MessageItemMetadata
228 item={item}
229 style={isFromSelf ? a.text_right : a.text_left}
230 />
231 )}
232 </View>
233 </>
234 )
235}
236MessageItem = React.memo(MessageItem)
237export {MessageItem}
238
239let MessageItemMetadata = ({
240 item,
241 style,
242}: {
243 item: ConvoItem & {type: 'message' | 'pending-message'}
244 style: StyleProp<TextStyle>
245}): React.ReactNode => {
246 const t = useTheme()
247 const {_} = useLingui()
248 const {message} = item
249
250 const handleRetry = useCallback(
251 (e: GestureResponderEvent) => {
252 if (item.type === 'pending-message' && item.retry) {
253 e.preventDefault()
254 item.retry()
255 return false
256 }
257 },
258 [item],
259 )
260
261 const relativeTimestamp = useCallback(
262 (i18n: I18n, timestamp: string) => {
263 const date = new Date(timestamp)
264 const now = new Date()
265
266 const time = i18n.date(date, {
267 hour: 'numeric',
268 minute: 'numeric',
269 })
270
271 const diff = now.getTime() - date.getTime()
272
273 // if under 30 seconds
274 if (diff < 1000 * 30) {
275 return _(msg`Now`)
276 }
277
278 return time
279 },
280 [_],
281 )
282
283 return (
284 <Text
285 style={[
286 a.text_xs,
287 a.mt_2xs,
288 a.mb_lg,
289 t.atoms.text_contrast_medium,
290 style,
291 ]}>
292 <TimeElapsed timestamp={message.sentAt} timeToString={relativeTimestamp}>
293 {({timeElapsed}) => (
294 <Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
295 {timeElapsed}
296 </Text>
297 )}
298 </TimeElapsed>
299
300 {item.type === 'pending-message' && item.failed && (
301 <>
302 {' '}
303 ·{' '}
304 <Text
305 style={[
306 a.text_xs,
307 {
308 color: t.palette.negative_400,
309 },
310 ]}>
311 {_(msg`Failed to send`)}
312 </Text>
313 {item.retry && (
314 <>
315 {' '}
316 ·{' '}
317 <InlineLinkText
318 label={_(msg`Click to retry failed message`)}
319 to="#"
320 onPress={handleRetry}
321 style={[a.text_xs]}>
322 {_(msg`Retry`)}
323 </InlineLinkText>
324 </>
325 )}
326 </>
327 )}
328 </Text>
329 )
330}
331MessageItemMetadata = React.memo(MessageItemMetadata)
332export {MessageItemMetadata}