Bluesky app fork with some witchin' additions 馃挮
at feat/tealfm 332 lines 9.8 kB view raw
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 &middot;{' '} 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 &middot;{' '} 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}