Bluesky app fork with some witchin' additions 💫
1import {memo, useEffect, useMemo, useRef} from 'react'
2import {
3 type GestureResponderEvent,
4 LayoutAnimation,
5 Pressable,
6 type StyleProp,
7 type TextStyle,
8 View,
9 type ViewStyle,
10} from 'react-native'
11import Animated, {
12 FadeIn,
13 FadeOut,
14 LayoutAnimationConfig,
15 LinearTransition,
16 useAnimatedStyle,
17 useSharedValue,
18 withTiming,
19 ZoomIn,
20 ZoomOut,
21} from 'react-native-reanimated'
22import {
23 AppBskyEmbedRecord,
24 ChatBskyConvoDefs,
25 RichText as RichTextAPI,
26} from '@atproto/api'
27import {plural} from '@lingui/core/macro'
28import {Trans, useLingui} from '@lingui/react/macro'
29import {useQueryClient} from '@tanstack/react-query'
30
31import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
32import {makeProfileLink} from '#/lib/routes/links'
33import {useConvoActive} from '#/state/messages/convo'
34import {type ConvoItem} from '#/state/messages/convo/types'
35import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
36import {useModerationOpts} from '#/state/preferences/moderation-opts'
37import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'
38import {useSession} from '#/state/session'
39import {atoms as a, native, platform, useTheme} from '#/alf'
40import {isOnlyEmoji} from '#/alf/typography'
41import {useDialogControl} from '#/components/Dialog'
42import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
43import {InlineLinkText, Link} from '#/components/Link'
44import * as ProfileCard from '#/components/ProfileCard'
45import {RichText} from '#/components/RichText'
46import {Text} from '#/components/Typography'
47import type * as bsky from '#/types/bsky'
48import {DateDivider} from './DateDivider'
49import {useDateDividerToggle} from './DateDividerToggle'
50import {MessageItemEmbed} from './MessageItemEmbed'
51import {ReactionsDialog} from './ReactionsDialog'
52
53const AVATAR_SIZE = 28
54const CLUSTERED_MESSAGE_GAP = 2
55const SQUARED_BORDER_RADIUS = 4
56const DISPLAY_NAME_INSET = 22
57
58const CLUSTERED_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000
59const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
60
61const TAP_AND_DRAG_DELAY_MS = 100
62
63function isWithinClusterBoundary({
64 isPending,
65 adjacentMessage,
66 isFromSameSender,
67 currentSentAt,
68 direction,
69}: {
70 isPending: boolean
71 adjacentMessage:
72 | ChatBskyConvoDefs.MessageView
73 | ChatBskyConvoDefs.DeletedMessageView
74 | null
75 isFromSameSender: boolean
76 currentSentAt: string
77 direction: 'prev' | 'next'
78}): boolean {
79 if (!isFromSameSender) return true
80 if (isPending && adjacentMessage) return false
81 if (ChatBskyConvoDefs.isMessageView(adjacentMessage)) {
82 const thisDate = new Date(currentSentAt)
83 const adjDate = new Date(adjacentMessage.sentAt)
84 const diff =
85 direction === 'next'
86 ? adjDate.getTime() - thisDate.getTime()
87 : thisDate.getTime() - adjDate.getTime()
88 return diff > CLUSTERED_MESSAGE_THRESHOLD_MS
89 }
90 return true
91}
92
93let MessageItem = ({
94 item,
95 isGroupChat = false,
96 profile,
97}: {
98 item: ConvoItem & {type: 'message' | 'pending-message'}
99 isGroupChat?: boolean
100 profile?: bsky.profile.AnyProfileView
101}): React.ReactNode => {
102 const enableSquareButtons = useEnableSquareButtons()
103 const t = useTheme()
104 const {currentAccount} = useSession()
105 const {t: l} = useLingui()
106 const {convo} = useConvoActive()
107 const moderationOpts = useModerationOpts()
108 const queryClient = useQueryClient()
109
110 const reactionsControl = useDialogControl()
111 const reactionTapRef = useRef(false)
112
113 const {message, nextMessage, prevMessage} = item
114 const isPending = item.type === 'pending-message'
115
116 const displayName = profile ? createSanitizedDisplayName(profile) : null
117
118 const isFromSelf =
119 message.sender?.did != null && message.sender.did === currentAccount?.did
120
121 const prevIsMessage = ChatBskyConvoDefs.isMessageView(prevMessage)
122 const nextIsMessage = ChatBskyConvoDefs.isMessageView(nextMessage)
123
124 const isPrevFromSameSender =
125 prevIsMessage &&
126 prevMessage.sender?.did === message.sender?.did &&
127 message.sender?.did != null
128 const isNextFromSameSender =
129 nextIsMessage &&
130 nextMessage.sender?.did === message.sender?.did &&
131 message.sender?.did != null
132
133 const isFirstInCluster = isWithinClusterBoundary({
134 isPending,
135 adjacentMessage: prevMessage,
136 isFromSameSender: isPrevFromSameSender,
137 currentSentAt: message.sentAt,
138 direction: 'prev',
139 })
140
141 const isLastInCluster = isWithinClusterBoundary({
142 isPending,
143 adjacentMessage: nextMessage,
144 isFromSameSender: isNextFromSameSender,
145 currentSentAt: message.sentAt,
146 direction: 'next',
147 })
148
149 const hasLargeGapFromPrev =
150 !ChatBskyConvoDefs.isMessageView(prevMessage) ||
151 new Date(message.sentAt).getTime() -
152 new Date(prevMessage.sentAt).getTime() >
153 MESSAGE_GAP_THRESHOLD_MS
154
155 const {isDividerToggled, toggleDivider} = useDateDividerToggle()
156 const isDateDividerToggled = isDividerToggled(message.id)
157 const isNextDateDividerToggled =
158 nextMessage != null && isDividerToggled(nextMessage.id)
159
160 const effectiveFirstInCluster = isFirstInCluster || isDateDividerToggled
161 const effectiveLastInCluster = isLastInCluster || isNextDateDividerToggled
162 const isInCluster = !(effectiveFirstInCluster && effectiveLastInCluster)
163 const isInMiddleOfCluster =
164 isInCluster && !effectiveFirstInCluster && !effectiveLastInCluster
165
166 const hasReactions = message.reactions && message.reactions.length > 0
167 const prevHasReactions =
168 prevIsMessage && prevMessage.reactions && prevMessage.reactions.length > 0
169 const squaredBottomCorner =
170 !hasReactions &&
171 isInCluster &&
172 (isInMiddleOfCluster || effectiveFirstInCluster)
173 const squaredTopCorner =
174 !prevHasReactions &&
175 isInCluster &&
176 (isInMiddleOfCluster || effectiveLastInCluster)
177
178 const pendingColor = t.palette.primary_300
179 const borderRadius = enableSquareButtons ? 4 : 18
180
181 const rt = new RichTextAPI({text: message.text, facets: message.facets})
182
183 const hasEmbedAndText =
184 AppBskyEmbedRecord.isView(message.embed) && rt.text.length > 0
185
186 const targetBottomRadius =
187 squaredBottomCorner || hasEmbedAndText
188 ? SQUARED_BORDER_RADIUS
189 : borderRadius
190 const targetTopRadius = squaredTopCorner
191 ? SQUARED_BORDER_RADIUS
192 : borderRadius
193
194 const bottomRadiusSV = useSharedValue(targetBottomRadius)
195 const topRadiusSV = useSharedValue(targetTopRadius)
196
197 const showDisplayName =
198 isGroupChat && !isFromSelf && isFirstInCluster && !isOnlyEmoji(message.text)
199 const showAvatar = isGroupChat && !isFromSelf && isLastInCluster
200
201 useEffect(() => {
202 bottomRadiusSV.set(withTiming(targetBottomRadius, {duration: 300}))
203 }, [targetBottomRadius, bottomRadiusSV])
204
205 useEffect(() => {
206 topRadiusSV.set(withTiming(targetTopRadius, {duration: 300}))
207 }, [targetTopRadius, topRadiusSV])
208
209 const borderRadiusStyle = useAnimatedStyle(() =>
210 isFromSelf
211 ? {
212 borderBottomRightRadius: bottomRadiusSV.get(),
213 borderTopRightRadius: topRadiusSV.get(),
214 }
215 : {
216 borderBottomLeftRadius: bottomRadiusSV.get(),
217 borderTopLeftRadius: topRadiusSV.get(),
218 },
219 )
220
221 const avatar =
222 profile && moderationOpts ? (
223 <Link
224 label={l`${createSanitizedDisplayName(profile)}’s avatar`}
225 accessibilityHint={l`Opens this profile`}
226 to={makeProfileLink({
227 did: profile.did,
228 handle: profile.handle,
229 })}
230 onPress={() => unstableCacheProfileView(queryClient, profile)}>
231 <ProfileCard.Avatar
232 profile={profile}
233 size={AVATAR_SIZE}
234 moderationOpts={moderationOpts}
235 disabledPreview
236 />
237 </Link>
238 ) : (
239 <ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} />
240 )
241
242 const groupedReactions = useMemo(() => {
243 const reactions = message.reactions ?? []
244 const grouped = new Map<
245 string,
246 {
247 key: string
248 value: string
249 senders: ChatBskyConvoDefs.ReactionViewSender[]
250 count: number
251 }
252 >()
253 for (const reaction of reactions) {
254 if (!reaction) continue
255 const existing = grouped.get(reaction.value)
256 if (existing) {
257 existing.senders.push(reaction.sender)
258 existing.count++
259 } else {
260 grouped.set(reaction.value, {
261 key: reaction.value,
262 value: reaction.value,
263 senders: [reaction.sender],
264 count: 1,
265 })
266 }
267 }
268 return Array.from(grouped.values())
269 }, [message.reactions])
270
271 const reactions = useMemo(() => message.reactions ?? [], [message.reactions])
272
273 const reactionsLabel = useMemo(() => {
274 if (reactions.length === 0) return ''
275 if (reactions.length === 1) {
276 const reaction = reactions[0]
277 const sender = reaction.sender
278 if (sender.did === currentAccount?.did) {
279 return l`You reacted ${reaction.value}`
280 } else {
281 const senderDid = reaction.sender.did
282 const memberSender = convo.members.find(
283 member => member.did === senderDid,
284 )
285 if (memberSender) {
286 return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}`
287 }
288 return l`Someone reacted ${reaction.value}`
289 }
290 }
291 return l`${plural(reactions.length, {
292 one: '# person',
293 other: '# people',
294 })} reacted – ${groupedReactions.map(g => g.value).join(' ')}`
295 }, [reactions, groupedReactions, currentAccount?.did, convo.members, l])
296
297 const appliedReactions = (
298 <LayoutAnimationConfig skipEntering skipExiting>
299 {hasReactions ? (
300 <View
301 style={[
302 a.relative,
303 a.bottom_0,
304 isFromSelf ? [a.align_end] : [a.ml_sm, a.align_start],
305 a.px_sm,
306 ]}>
307 <Pressable
308 accessible={true}
309 accessibilityLabel={reactionsLabel}
310 accessibilityHint={
311 isGroupChat ? l`Tap to view reactions` : undefined
312 }
313 style={[
314 a.flex_row,
315 a.gap_2xs,
316 a.px_xs,
317 isFromSelf ? a.justify_end : a.justify_start,
318 a.flex_wrap,
319 a.rounded_lg,
320 a.border,
321 t.atoms.border_contrast_low,
322 t.atoms.bg_contrast_25,
323 t.atoms.shadow_sm,
324 {
325 paddingTop: platform({android: 2, default: 3}),
326 paddingBottom: platform({android: 2, default: 3}),
327 transform: [{translateY: -8}],
328 },
329 ]}
330 onPressIn={() => {
331 // Don't toggle the date divider when tapping a reaction.
332 reactionTapRef.current = true
333 }}
334 onPressOut={() => {
335 // Include a delay here to account for tap-and-drag before release.
336 setTimeout(() => {
337 reactionTapRef.current = false
338 }, TAP_AND_DRAG_DELAY_MS)
339 }}
340 onPress={isGroupChat ? reactionsControl.open : undefined}>
341 {groupedReactions.map(group => (
342 <Animated.View
343 entering={native(ZoomIn.springify(200).delay(400))}
344 exiting={
345 groupedReactions.length > 1
346 ? native(ZoomOut.delay(200))
347 : undefined
348 }
349 layout={native(LinearTransition.delay(300))}
350 key={group.value}
351 style={[a.py_2xs]}>
352 <Text
353 emoji
354 style={[
355 a.text_xs,
356 {textAlignVertical: 'center', includeFontPadding: false},
357 ]}>
358 {group.value}
359 </Text>
360 </Animated.View>
361 ))}
362 {groupedReactions.length !== reactions.length &&
363 reactions.length > 1 ? (
364 <View style={[a.p_2xs, a.pl_0, a.justify_center]}>
365 <Text
366 style={[
367 a.text_xs,
368 t.atoms.text_contrast_medium,
369 {textAlignVertical: 'center', includeFontPadding: false},
370 ]}>
371 {reactions.length}
372 </Text>
373 </View>
374 ) : null}
375 </Pressable>
376 </View>
377 ) : null}
378 <ReactionsDialog
379 control={reactionsControl}
380 members={convo.members}
381 message={message}
382 reactions={message.reactions}
383 groupedReactions={groupedReactions}
384 />
385 </LayoutAnimationConfig>
386 )
387
388 const messageInset = platform<ViewStyle | undefined>({
389 ios: isFromSelf ? a.mr_md : isGroupChat ? a.ml_md : a.ml_sm,
390 android: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined,
391 web: isFromSelf ? a.mr_sm : isGroupChat ? a.ml_sm : undefined,
392 })
393
394 return (
395 <>
396 {(hasLargeGapFromPrev || isDateDividerToggled) && (
397 <Animated.View entering={native(FadeIn)} exiting={native(FadeOut)}>
398 <DateDivider date={message.sentAt} />
399 </Animated.View>
400 )}
401 <View style={[messageInset, effectiveFirstInCluster && a.mt_md]}>
402 <View style={[a.relative]}>
403 {showAvatar ? (
404 <View
405 style={[
406 a.absolute,
407 a.bottom_0,
408 a.z_50,
409 {
410 transform: [{translateY: hasReactions ? -24 : 0}],
411 },
412 ]}>
413 {avatar}
414 </View>
415 ) : null}
416 <View
417 style={[
418 a.flex_grow,
419 !isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE},
420 ]}>
421 {displayName && showDisplayName ? (
422 <Text
423 style={[
424 a.text_xs,
425 t.atoms.text_contrast_medium,
426 a.pt_xs,
427 a.pb_2xs,
428 {
429 paddingLeft: DISPLAY_NAME_INSET,
430 },
431 ]}>
432 {displayName}
433 </Text>
434 ) : null}
435 <ActionsWrapper
436 hasReactions={hasReactions}
437 isFromSelf={isFromSelf}
438 message={message}
439 onTap={() => {
440 if (reactionTapRef.current) return
441 if (!hasLargeGapFromPrev) {
442 LayoutAnimation.configureNext(
443 LayoutAnimation.Presets.easeInEaseOut,
444 )
445 toggleDivider(message.id)
446 }
447 }}>
448 {rt.text.length > 0 && (
449 <Animated.View
450 accessibilityHint={l`Double tap or long press the message to add a reaction`}
451 style={[
452 !isFromSelf && a.ml_sm,
453 ...(isOnlyEmoji(message.text)
454 ? []
455 : [
456 enableSquareButtons ? a.rounded_sm : a.rounded_xl,
457 a.py_sm,
458 a.px_md,
459 {
460 marginTop: effectiveFirstInCluster
461 ? 0
462 : CLUSTERED_MESSAGE_GAP,
463 backgroundColor: isFromSelf
464 ? isPending
465 ? pendingColor
466 : t.palette.primary_500
467 : t.palette.contrast_50,
468 },
469 isFromSelf ? a.self_end : a.self_start,
470 borderRadiusStyle,
471 ]),
472 ]}>
473 <RichText
474 value={rt}
475 style={[
476 a.text_md,
477 isFromSelf && {color: t.palette.white},
478 // Emoji-only: add top leading to avoid clipping the
479 // glyph, then pull the bottom up by the same amount so
480 // the glyph bottom-aligns with the avatar instead of
481 // sitting above its line-box baseline.
482 isOnlyEmoji(message.text) && [
483 a.leading_tight,
484 // Visually align bottom of the emoji with the avatar
485 !isFromSelf &&
486 platform({
487 android: {marginTop: a.mt_2xs.marginTop},
488 default: {marginBottom: -a.mb_sm.marginBottom},
489 }),
490 ],
491 ]}
492 interactiveStyle={a.underline}
493 enableTags
494 emojiMultiplier={3}
495 shouldProxyLinks={true}
496 />
497 </Animated.View>
498 )}
499 {AppBskyEmbedRecord.isView(message.embed) && (
500 <MessageItemEmbed
501 embed={message.embed}
502 isFromSelf={isFromSelf}
503 squaredBottomCorner={squaredBottomCorner}
504 squaredTopCorner={squaredTopCorner || hasEmbedAndText}
505 />
506 )}
507 {appliedReactions}
508 </ActionsWrapper>
509 </View>
510 </View>
511 {effectiveLastInCluster && (
512 <MessageItemMetadata
513 item={item}
514 style={[isFromSelf ? a.text_right : a.text_left]}
515 />
516 )}
517 </View>
518 </>
519 )
520}
521MessageItem = memo(MessageItem)
522export {MessageItem}
523
524let MessageItemMetadata = ({
525 item,
526 style,
527}: {
528 item: ConvoItem & {type: 'message' | 'pending-message'}
529 style: StyleProp<TextStyle>
530}): React.ReactNode => {
531 const t = useTheme()
532 const {t: l} = useLingui()
533
534 const handleRetry = (e: GestureResponderEvent) => {
535 if (item.type === 'pending-message' && item.retry) {
536 e.preventDefault()
537 item.retry()
538 return false
539 }
540 }
541
542 const errorColor = t.palette.negative_400
543
544 switch (item.type) {
545 case 'pending-message':
546 return item.failed ? (
547 <Text style={[a.text_xs, a.my_2xs, {color: errorColor}, style]}>
548 <Text style={[a.text_xs, {color: errorColor}]}>
549 <Trans>Message failed to send.</Trans>
550 </Text>
551 {item.retry && (
552 <>
553 {' '}
554 <InlineLinkText
555 label={l`Click to retry failed message`}
556 to="#"
557 onPress={handleRetry}
558 style={[a.text_xs, {color: errorColor}]}>
559 <Trans>Tap to retry</Trans>
560 </InlineLinkText>
561 .
562 </>
563 )}
564 </Text>
565 ) : null
566 default:
567 return null
568 }
569}
570MessageItemMetadata = memo(MessageItemMetadata)
571export {MessageItemMetadata}