Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at cope-settings-sync 571 lines 19 kB view raw
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}