Something went wrong. Try again.
Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
33 kB · 1015 lines
TSX
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016import {memo, useEffect, useMemo} from 'react'import { type GestureResponderEvent, Pressable, type StyleProp, type TextStyle, View, type ViewStyle,} from 'react-native'import Animated, { type AnimatedStyle, FadeIn, FadeOut, interpolateColor, LayoutAnimationConfig, LinearTransition, useAnimatedStyle, useSharedValue, withDelay, withSequence, withTiming, ZoomIn, ZoomOut,} from 'react-native-reanimated'import {moderateProfile} from '@bsky/sdk/moderation'import {RichText as RichTextAPI} from '@bsky/sdk/richtext'import {plural} from '@lingui/core/macro'import {Trans, useLingui} from '@lingui/react/macro'import {useQueryClient} from '@tanstack/react-query'
import {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'import {getShapeRadius, getShapeStyle} from '#/lib/shapes'import {sanitizeHandle} from '#/lib/strings/handles'import {useMaybeProfileShadow} from '#/state/cache/profile-shadow'import {type Shadow} from '#/state/cache/types'import {type ConvoItem} from '#/state/messages/convo/types'import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'import {useModerationOpts} from '#/state/preferences/moderation-opts'import {useProfileBlockMutationQueue} from '#/state/queries/profile'import {useUnavailableProfileLabel} from '#/state/queries/unavailable-content'import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache'import {useSession} from '#/state/session'import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'import {atoms as a, native, platform, tokens, useTheme, utils} from '#/alf'import {isOnlyEmoji} from '#/alf/typography'import {Button} from '#/components/Button'import {ActionsWrapper} from '#/components/dms/ActionsWrapper'import {useMessageDialogs} from '#/components/dms/MessageOverlays'import {useMessageReplies} from '#/components/dms/MessageReplies'import {useReplyPreviewText} from '#/components/dms/replyPreview'import {ArrowCornerDownRight_Stroke2_Corner3_Rounded as ArrowCornerDownRightIcon} from '#/components/icons/ArrowCornerDownRight'import {InlineLinkText} from '#/components/Link'import * as ProfileCard from '#/components/ProfileCard'import * as Prompt from '#/components/Prompt'import {RichText} from '#/components/RichText'import {Text} from '#/components/Typography'import {accentForeground} from '#/features/themes/accentForeground'import {app, chat} from '#/lexicons'import * as bsky from '#/types/bsky'import {DateDivider} from './DateDivider'import {MessageItemEmbed} from './MessageItemEmbed'import {MessageItemInviteEmbed} from './MessageItemInviteEmbed'import {groupReactions} from './ReactionsDialog'import { CLUSTERED_MESSAGE_THRESHOLD_MS, filterBlockedReactions, MESSAGE_BUBBLE_MAX_WIDTH, MESSAGE_GAP_THRESHOLD_MS,} from './util'
const AVATAR_SIZE = 28const CLUSTERED_MESSAGE_GAP = 2const SQUARED_BORDER_RADIUS = 4const DISPLAY_NAME_INSET = 20
export type MessageItemNeighbor = | chat.bsky.convo.defs.MessageView | chat.bsky.convo.defs.DeletedMessageView | null
function messageIsReply(message: MessageItemNeighbor): boolean { return ( bsky.isType(chat.bsky.convo.defs.messageView, message) && (bsky.isType(chat.bsky.convo.defs.messageView, message.replyTo) || bsky.isType(chat.bsky.convo.defs.deletedMessageView, message.replyTo) || bsky.isType( chat.bsky.convo.defs.messageBeforeUserJoinedGroupView, message.replyTo, )) )}
function isWithinClusterBoundary({ isPending, message, adjacentMessage, isFromSameSender, direction,}: { isPending: boolean message: chat.bsky.convo.defs.MessageView adjacentMessage: MessageItemNeighbor isFromSameSender: boolean direction: 'prev' | 'next'}): boolean { // A reply always starts its own cluster, breaking grouping with the message // above it. Looking back, that's a boundary if this message is a reply; // looking forward, it's a boundary if the next message is a reply. if (messageIsReply(direction === 'prev' ? message : adjacentMessage)) { return true } if (!isFromSameSender) return true if (bsky.isType(chat.bsky.convo.defs.messageView, adjacentMessage)) { const currentSentAt = message.sentAt const thisDate = new Date(currentSentAt) const adjDate = new Date(adjacentMessage.sentAt) const diff = direction === 'next' ? adjDate.getTime() - thisDate.getTime() : thisDate.getTime() - adjDate.getTime() const isOutsideThreshold = diff > CLUSTERED_MESSAGE_THRESHOLD_MS // For pending messages, still check the time threshold if (isPending) return isOutsideThreshold return isOutsideThreshold } return true}
let MessageItem = ({ item, isGroupChat = false, prevMessage, nextMessage, relatedProfiles,}: { item: ConvoItem & {type: 'message' | 'pending-message'} isGroupChat?: boolean prevMessage: MessageItemNeighbor nextMessage: MessageItemNeighbor relatedProfiles: Map<string, chat.bsky.actor.defs.ProfileViewBasic>}): React.ReactNode => { const enableSquareButtons = useEnableSquareButtons() const t = useTheme() const {currentAccount} = useSession() const {t: l} = useLingui() const moderationOpts = useModerationOpts() const queryClient = useQueryClient()
const {message} = item const profile = useMaybeProfileShadow(relatedProfiles.get(message.sender.did))
const {openReactions} = useMessageDialogs() const {scrollToMessage, highlightedMessage} = useMessageReplies()
// `replyTo` comes back hydrated as the referenced message, a deleted-message // tombstone, or a before-joined placeholder. Narrow away the open-union // fallback so we only render shapes we understand. const replyTo = bsky.isType(chat.bsky.convo.defs.messageView, message.replyTo) || bsky.isType(chat.bsky.convo.defs.deletedMessageView, message.replyTo) || bsky.isType( chat.bsky.convo.defs.messageBeforeUserJoinedGroupView, message.replyTo, ) ? message.replyTo : undefined const replyToMessageId = replyTo && !bsky.isType(chat.bsky.convo.defs.messageBeforeUserJoinedGroupView, replyTo) ? replyTo.id : undefined const onPressReplyTo = replyToMessageId ? () => scrollToMessage(replyToMessageId) : undefined
const isPending = item.type === 'pending-message'
const unavailableLabel = useUnavailableProfileLabel(profile) const displayName = profile ? (unavailableLabel ?? createSanitizedDisplayName(profile)) : null
const isFromSelf = message.sender?.did != null && message.sender.did === currentAccount?.did
const prevIsMessage = bsky.isType( chat.bsky.convo.defs.messageView, prevMessage, ) const nextIsMessage = bsky.isType( chat.bsky.convo.defs.messageView, nextMessage, )
const isPrevFromSameSender = prevIsMessage && prevMessage.sender?.did === message.sender?.did && message.sender?.did != null const isNextFromSameSender = nextIsMessage && nextMessage.sender?.did === message.sender?.did && message.sender?.did != null
const isFirstInCluster = isWithinClusterBoundary({ isPending, message, adjacentMessage: prevMessage, isFromSameSender: isPrevFromSameSender, direction: 'prev', })
const isLastInCluster = isWithinClusterBoundary({ isPending, message, adjacentMessage: nextMessage, isFromSameSender: isNextFromSameSender, direction: 'next', })
const hasLargeGapFromPrev = !bsky.isType(chat.bsky.convo.defs.messageView, prevMessage) || new Date(message.sentAt).getTime() - new Date(prevMessage.sentAt).getTime() > MESSAGE_GAP_THRESHOLD_MS
const isInCluster = !(isFirstInCluster && isLastInCluster) const isInMiddleOfCluster = isInCluster && !isFirstInCluster && !isLastInCluster
const visibleReactions = useMemo( () => filterBlockedReactions(message.reactions, relatedProfiles), [message.reactions, relatedProfiles], )
const hasReactions = visibleReactions.length > 0 const prevHasReactions = prevIsMessage && filterBlockedReactions(prevMessage.reactions, relatedProfiles).length > 0 const isNextEmojiOnly = nextIsMessage && isOnlyEmoji(nextMessage.text) const isPrevEmojiOnly = prevIsMessage && isOnlyEmoji(prevMessage.text) const squaredBottomCorner = !hasReactions && !isNextEmojiOnly && isInCluster && (isInMiddleOfCluster || isFirstInCluster) const squaredTopCorner = !prevHasReactions && !isPrevEmojiOnly && isInCluster && (isInMiddleOfCluster || isLastInCluster)
const pendingColor = t.palette.primary_300 const squaredBorderRadius = getShapeRadius( enableSquareButtons, SQUARED_BORDER_RADIUS, SQUARED_BORDER_RADIUS, ) const borderRadius = getShapeRadius(enableSquareButtons, 4, 18)
const displayNameInset = enableSquareButtons ? a.ml_sm.marginLeft : DISPLAY_NAME_INSET
const bubbleColor = isFromSelf ? isPending ? pendingColor : t.palette.primary_500 : t.palette.contrast_50 const highlightColor = isFromSelf ? t.palette.primary_300 : t.palette.primary_100
const rt = new RichTextAPI({ text: message.text, facets: message.facets, })
const isEmojiOnly = isOnlyEmoji(message.text)
const hasEmbed = bsky.isType(app.bsky.embed.record.view, message.embed) || bsky.isType(chat.bsky.embed.joinLink.view, message.embed) const hasEmbedAndText = hasEmbed && rt.text.length > 0
const targetBottomRadius = squaredBottomCorner ? squaredBorderRadius : borderRadius const targetTopRadius = squaredTopCorner || hasEmbedAndText ? squaredBorderRadius : borderRadius
const bottomRadiusSV = useSharedValue(targetBottomRadius) const topRadiusSV = useSharedValue(targetTopRadius)
const showDisplayName = isGroupChat && !isFromSelf && isFirstInCluster && !isEmojiOnly const showAvatar = isGroupChat && !isFromSelf && isLastInCluster
/* * Emoji-only messages have no bubble background (see the `!isOnlyEmoji` gate * on the bubble styling below), so the corner-radius animation is invisible * overhead for them. Worse, on Android the resulting re-layout of the parent * Animated.View re-measures the enlarged emoji `<Text>` and some Android * device's text stack drops the trailing glyph on that second pass (while * keeping its reserved width). Set the radii directly for emoji-only messages * so nothing re-lays-out the glyph after its initial paint. */ useEffect(() => { bottomRadiusSV.set( isEmojiOnly ? targetBottomRadius : withTiming(targetBottomRadius, {duration: 300}), ) }, [targetBottomRadius, bottomRadiusSV, isEmojiOnly])
useEffect(() => { topRadiusSV.set( isEmojiOnly ? targetTopRadius : withTiming(targetTopRadius, {duration: 300}), ) }, [targetTopRadius, topRadiusSV, isEmojiOnly])
// Flash the message background when it's been scrolled to (e.g. by tapping a // reply that quotes it), so it's easy to spot. Keyed on the highlight `key` // so re-tapping the same message re-triggers the flash. const highlightSV = useSharedValue(0) const isHighlighted = highlightedMessage?.id === message.id const highlightKey = isHighlighted ? highlightedMessage.key : null useEffect(() => { if (highlightKey === null) return highlightSV.set( withSequence( withTiming(1, {duration: 150}), withDelay(400, withTiming(0, {duration: 450})), ), ) }, [highlightKey, highlightSV])
const highlightStyle = useAnimatedStyle(() => ({ backgroundColor: interpolateColor( highlightSV.get(), [0, 1], [bubbleColor, highlightColor], ), }))
const borderRadiusStyle = useAnimatedStyle(() => isFromSelf ? { borderBottomRightRadius: bottomRadiusSV.get(), borderTopRightRadius: topRadiusSV.get(), } : { borderBottomLeftRadius: bottomRadiusSV.get(), borderTopLeftRadius: topRadiusSV.get(), }, )
const avatar = profile && moderationOpts ? ( <PreviewableUserAvatar profile={profile} size={AVATAR_SIZE} type={profile.associated?.labeler ? 'labeler' : 'user'} onBeforePress={() => unstableCacheProfileView(queryClient, profile)} moderation={moderateProfile(profile, moderationOpts).ui('avatar')} /> ) : ( <ProfileCard.AvatarPlaceholder size={AVATAR_SIZE} /> )
const groupedReactions = useMemo( () => groupReactions(visibleReactions), [visibleReactions], )
const reactions = visibleReactions
const hasSelfReacted = reactions.some( r => r.sender.did === currentAccount?.did, )
const reactionsLabel = useMemo(() => { if (reactions.length === 0) return '' if (reactions.length === 1) { const reaction = reactions[0] const sender = reaction.sender if (sender.did === currentAccount?.did) { return l`You reacted ${reaction.value}` } else { const senderDid = reaction.sender.did const memberSender = relatedProfiles.get(senderDid) if (memberSender) { return l`${createSanitizedDisplayName(memberSender)} reacted ${reaction.value}` } return l`Someone reacted ${reaction.value}` } } return l`${plural(reactions.length, { one: '# person', other: '# people', })} reacted – ${groupedReactions.map(g => g.value).join(' ')}` }, [reactions, groupedReactions, currentAccount?.did, relatedProfiles, l])
const appliedReactions = ( <LayoutAnimationConfig skipEntering skipExiting> {hasReactions ? ( <Animated.View entering={FadeIn} exiting={FadeOut} style={[ a.absolute, {top: '100%'}, isFromSelf ? [a.right_0] : [a.left_0, isGroupChat && a.ml_sm], a.px_sm, a.z_10, ]}> <Pressable accessible={true} accessibilityLabel={reactionsLabel} accessibilityHint={ isGroupChat ? l`Tap to view reactions` : undefined } style={[ a.flex_row, a.gap_2xs, isFromSelf ? a.justify_end : a.justify_start, a.rounded_lg, a.border, t.atoms.border_contrast_low, t.atoms.shadow_xs, a.px_sm, hasSelfReacted ? {backgroundColor: t.palette.primary_100} : t.atoms.bg_contrast_25, { paddingTop: platform({android: 2, default: 3}), paddingBottom: platform({android: 2, default: 3}), transform: [{translateY: -6}], }, ]} onPress={isGroupChat ? () => openReactions(message) : undefined}> {groupedReactions.slice(0, 10).map(group => ( <Animated.View entering={native(ZoomIn.springify(200).delay(400))} exiting={ groupedReactions.length > 1 ? native(ZoomOut.delay(200)) : undefined } layout={native(LinearTransition.delay(300))} key={group.value} style={[a.py_2xs]}> <Text emoji style={[ a.text_md, {textAlignVertical: 'center', includeFontPadding: false}, ]}> {group.value} </Text> </Animated.View> ))} {(groupedReactions.length !== reactions.length || groupedReactions.length > 10) && reactions.length > 1 ? ( <View style={[a.p_2xs, a.pl_0, a.justify_center]}> <Text style={[ a.text_sm, a.font_medium, hasSelfReacted ? {color: t.palette.primary_900} : t.atoms.text_contrast_high, {textAlignVertical: 'center', includeFontPadding: false}, ]}> {reactions.length} </Text> </View> ) : null} </Pressable> </Animated.View> ) : null} </LayoutAnimationConfig> )
const messageInset = platform<ViewStyle | undefined>({ android: a.mx_sm, ios: a.mx_md, web: a.mx_lg, })
return ( <> {hasLargeGapFromPrev && <DateDivider date={message.sentAt} />} <View {...{ dataSet: { keyboardNavigationItem: 'true', keyboardNavigationScope: 'messages', }, }} style={[ messageInset, isFirstInCluster ? a.mt_md : {marginTop: CLUSTERED_MESSAGE_GAP}, hasReactions && {paddingBottom: 26}, ]}> <View style={[a.relative]}> {showAvatar ? ( <View style={[a.absolute, a.bottom_0, a.z_50]}>{avatar}</View> ) : null} <View style={[ a.relative, a.flex_grow, !isFromSelf && isGroupChat && {paddingLeft: AVATAR_SIZE}, ]}> {replyTo ? ( <ReplyCaption replyTo={replyTo} isFromSelf={isFromSelf} displayNameInset={displayNameInset} replierDisplayName={displayName} relatedProfiles={relatedProfiles} onPress={onPressReplyTo} enableSquareButtons={!!enableSquareButtons} /> ) : displayName && showDisplayName ? ( <Text style={[ a.text_xs, t.atoms.text_contrast_medium, a.pt_xs, a.pb_2xs, {paddingLeft: displayNameInset}, ]} emoji> {displayName} </Text> ) : null} {profile && isBlockedOrBlocking(profile) && isGroupChat ? ( <BlockedPlaceholder profile={profile} style={borderRadiusStyle} /> ) : ( <View style={[a.relative]}> <ActionsWrapper isFromSelf={isFromSelf} message={message} senderProfile={profile} moderationOpts={moderationOpts}> {bsky.isType(app.bsky.embed.record.view, message.embed) && ( <MessageItemEmbed embed={message.embed} isFromSelf={isFromSelf} isGroupChat={isGroupChat} squaredBottomCorner={ squaredBottomCorner || hasEmbedAndText } squaredTopCorner={squaredTopCorner} highlightSV={highlightSV} /> )} {bsky.isType( chat.bsky.embed.joinLink.view, message.embed, ) && ( <MessageItemInviteEmbed embed={message.embed} isFromSelf={isFromSelf} isGroupChat={isGroupChat} squaredBottomCorner={ squaredBottomCorner || hasEmbedAndText } squaredTopCorner={squaredTopCorner} highlightSV={highlightSV} /> )} {rt.text.length > 0 && ( <Animated.View accessibilityHint={l`Double tap or long press the message to add a reaction`} style={[ !isFromSelf && isGroupChat && a.ml_sm, !isEmojiOnly && [ getShapeStyle( enableSquareButtons, a.rounded_sm, a.rounded_xl, ), a.py_sm, a.px_md, a.max_w_full, { marginTop: hasEmbedAndText ? CLUSTERED_MESSAGE_GAP : 0, }, isFromSelf ? a.self_end : a.self_start, borderRadiusStyle, highlightStyle, ], ]}> {replyTo && !isEmojiOnly ? ( <ReplyQuote replyTo={replyTo} isFromSelf={isFromSelf} relatedProfiles={relatedProfiles} onPress={onPressReplyTo} /> ) : null} <RichText value={rt} style={[ a.text_md, isFromSelf && { color: accentForeground(t, t.palette.primary_500), }, // Emoji-only: add top leading to avoid clipping the // glyph, then pull the bottom up by the same amount so // the glyph bottom-aligns with the avatar instead of // sitting above its line-box baseline. isEmojiOnly && [ a.leading_tight, // Visually align bottom of the emoji with the avatar !isFromSelf && platform({ android: {marginTop: a.mt_2xs.marginTop}, default: {marginBottom: -a.mb_sm.marginBottom}, }), ], ]} interactiveStyle={a.underline} enableTags emojiMultiplier={3} shouldProxyLinks={true} /> </Animated.View> )} </ActionsWrapper> {appliedReactions} </View> )} </View> </View> {isLastInCluster && ( <MessageItemMetadata item={item} style={[isFromSelf ? a.text_right : a.text_left]} /> )} </View> </> )}MessageItem = memo(MessageItem)export {MessageItem}
let MessageItemMetadata = ({ item, style,}: { item: ConvoItem & {type: 'message' | 'pending-message'} style: StyleProp<TextStyle>}): React.ReactNode => { const t = useTheme() const {t: l} = useLingui()
const handleRetry = (e: GestureResponderEvent) => { if (item.type === 'pending-message' && item.retry) { e.preventDefault() item.retry() return false } }
const errorColor = t.palette.negative_400
switch (item.type) { case 'pending-message': return item.failed ? ( <Text style={[a.text_xs, a.my_2xs, {color: errorColor}, style]}> <Text style={[a.text_xs, {color: errorColor}]}> <Trans>Message failed to send.</Trans> </Text> {item.retry && ( <> {' '} <InlineLinkText label={l`Click to retry failed message`} to="#" onPress={handleRetry} style={[a.text_xs, {color: errorColor}]}> <Trans>Tap to retry</Trans> </InlineLinkText> . </> )} </Text> ) : null default: return null }}MessageItemMetadata = memo(MessageItemMetadata)export {MessageItemMetadata}
function BlockedPlaceholder({ profile, style,}: { profile: Shadow<chat.bsky.actor.defs.ProfileViewBasic> style?: AnimatedStyle<ViewStyle>}) { const {t: l} = useLingui() const t = useTheme() const control = Prompt.usePromptControl() const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
return ( <> <Button style={[{maxWidth: MESSAGE_BUBBLE_MAX_WIDTH}, a.self_start]} label={ profile.viewer?.blocking ? l`This message is hidden because you are blocking this user.` : l`This message is hidden because this user is blocking you.` } accessibilityHint={l`Tap for details`} onPress={() => control.open()}> <Animated.View style={[ a.ml_sm, a.rounded_xl, a.py_sm, a.px_md, t.atoms.bg, a.self_start, a.border, t.atoms.border_contrast_high, a.flex_shrink, style, ]}> <Text style={[ a.text_sm, a.leading_snug, a.italic, t.atoms.text_contrast_medium, ]}> {profile.viewer?.blocking ? ( <Trans> This message is hidden because you are blocking this user. </Trans> ) : ( <Trans> This message is hidden because this user is blocking you. </Trans> )} </Text> </Animated.View> </Button> <Prompt.Outer control={control}> <Prompt.Content> <Prompt.TitleText> {profile.viewer?.blocking ? ( <Trans> You are blocking {sanitizeHandle(profile.handle, '@')} </Trans> ) : ( <Trans> {sanitizeHandle(profile.handle, '@')} is blocking you </Trans> )} </Prompt.TitleText> <Prompt.DescriptionText> {profile.viewer?.blocking ? ( <Trans> Messages from this person are hidden while you are blocking them. </Trans> ) : ( <Trans> Messages from this person are hidden while they are blocking you. </Trans> )} </Prompt.DescriptionText> <Prompt.Actions> <Prompt.Action onPress={() => {}} cta={l`Okay`} color="primary" /> {profile.viewer?.blocking && !profile.viewer.blockingByList && ( <Prompt.Action onPress={() => void queueUnblock()} cta={l`Unblock`} color="secondary" /> )} </Prompt.Actions> </Prompt.Content> </Prompt.Outer> </> )}
/** * The "↪ X replied to Y" caption rendered above a reply message, in place of * the display name. `X` is the person sending the reply (self -> "you"), `Y` is * the original sender. Tapping it scrolls to the original (if loaded). * * Aligns with the sender's display name for others (left), or with the message * bubble for self (right). */function ReplyCaption({ replyTo, isFromSelf, displayNameInset, replierDisplayName, relatedProfiles, onPress, enableSquareButtons,}: { replyTo: | chat.bsky.convo.defs.MessageView | chat.bsky.convo.defs.DeletedMessageView | chat.bsky.convo.defs.MessageBeforeUserJoinedGroupView isFromSelf: boolean displayNameInset: number replierDisplayName: string | null relatedProfiles: Map<string, chat.bsky.actor.defs.ProfileViewBasic> onPress?: () => void enableSquareButtons: boolean}) { const t = useTheme() const {t: l} = useLingui() const {currentAccount} = useSession()
const originalProfile = bsky.isType(chat.bsky.convo.defs.messageView, replyTo) || bsky.isType(chat.bsky.convo.defs.deletedMessageView, replyTo) ? relatedProfiles.get(replyTo.sender.did) : undefined const unavailableLabel = useUnavailableProfileLabel(originalProfile)
let caption: string = '' if ( bsky.isType(chat.bsky.convo.defs.messageView, replyTo) || bsky.isType(chat.bsky.convo.defs.deletedMessageView, replyTo) ) { const originalSenderIsSelf = replyTo.sender.did === currentAccount?.did const originalName = originalSenderIsSelf ? null : originalProfile ? (unavailableLabel ?? createSanitizedDisplayName(originalProfile)) : null
caption = isFromSelf ? originalSenderIsSelf ? l`You replied to yourself` : originalName ? l`You replied to ${originalName}` : l`You replied` : originalSenderIsSelf ? l`${replierDisplayName} replied to you` : originalName ? l`${replierDisplayName} replied to ${originalName}` : l`${replierDisplayName} replied` } else { caption = l`Someone replied` }
return ( <Button label={ onPress ? l`Scroll to the message this is replying to` : l`A reply to a message sent before you joined` } disabled={!onPress} onPress={onPress} style={[ a.w_full, a.flex_row, a.align_center, a.gap_2xs, a.pb_2xs, a.pt_xs, isFromSelf ? [a.justify_end, !enableSquareButtons && a.pr_md] : [a.justify_start, {paddingLeft: displayNameInset}], ]}> <ArrowCornerDownRightIcon size="xs" style={t.atoms.text_contrast_medium} /> <Text style={[a.text_xs, a.flex_shrink, t.atoms.text_contrast_medium]} numberOfLines={1} emoji> {caption} </Text> </Button> )}
/** * The nested quote of the original message, rendered at the top of a reply * bubble. Tapping it scrolls to the original (if loaded). */function ReplyQuote({ replyTo, isFromSelf, relatedProfiles, onPress,}: { replyTo: | chat.bsky.convo.defs.MessageView | chat.bsky.convo.defs.DeletedMessageView | chat.bsky.convo.defs.MessageBeforeUserJoinedGroupView isFromSelf: boolean relatedProfiles: Map<string, chat.bsky.actor.defs.ProfileViewBasic> onPress?: () => void}) { const enableSquareButtons = useEnableSquareButtons() const t = useTheme() const {t: l} = useLingui() const getReplyPreviewText = useReplyPreviewText()
const senderDid = bsky.isType(chat.bsky.convo.defs.messageView, replyTo) || bsky.isType(chat.bsky.convo.defs.deletedMessageView, replyTo) ? replyTo.sender.did : undefined const senderProfile = useMaybeProfileShadow( senderDid ? relatedProfiles.get(senderDid) : undefined, ) // Hide the quoted content if we block, or are blocked by, the original // sender - mirroring how the message bubble itself is hidden. const isBlocked = senderProfile ? isBlockedOrBlocking(senderProfile) : false const unavailableLabel = useUnavailableProfileLabel(senderProfile) const senderName = senderProfile && !isBlocked ? (unavailableLabel ?? createSanitizedDisplayName(senderProfile)) : null
const tintColor = isFromSelf ? accentForeground(t, t.palette.primary_500) : t.atoms.text.color const subtleColor = isFromSelf ? accentForeground(t, t.palette.primary_500) : t.atoms.text_contrast_high.color const borderColor = isFromSelf ? utils.alpha(accentForeground(t, t.palette.primary_500), 0.5) : t.atoms.border_contrast_high.borderColor
let text: string let subtle = false if (isBlocked) { text = l({ message: '(blocked message hidden)', comment: 'A reply summary in chat', }) subtle = true } else if (bsky.isType(chat.bsky.convo.defs.messageView, replyTo)) { ;({text, subtle} = getReplyPreviewText(replyTo)) } else if ( bsky.isType(chat.bsky.convo.defs.messageBeforeUserJoinedGroupView, replyTo) ) { text = l({ message: `(message sent before you joined)`, comment: 'A reply summary in chat', }) subtle = true } else { text = l({message: '(deleted message)', comment: 'A reply summary in chat'}) subtle = true }
return ( <Button label={ !onPress ? l`Replied-to message was sent before you joined` : senderName ? l`Replied-to message from ${senderName}, tap to scroll to it` : l`Replied-to message, tap to scroll to it` } disabled={!onPress} onPress={onPress} style={[ a.mb_xs, getShapeStyle(enableSquareButtons, a.rounded_sm, a.rounded_md), a.p_sm, // The padding above is a little loose, so we tighten it up here. {paddingTop: tokens.space.sm - 2}, a.flex_col, a.align_start, a.border, {borderColor, marginHorizontal: -4}, ]}> {senderName ? ( <Text style={[a.text_xs, {color: subtleColor}]} emoji numberOfLines={1}> {senderName} </Text> ) : null} <Text style={[ a.text_sm, {color: subtle ? subtleColor : tintColor}, subtle && a.italic, ]} emoji numberOfLines={2}> {text} </Text> </Button> )}