Bluesky app fork with some witchin' additions 馃挮
at 5ee667f307bc459ba53cdaabdad00a0ea1ee6846 433 lines 12 kB view raw
1import {memo, useMemo, useState} from 'react' 2import {type StyleProp, View, type ViewStyle} from 'react-native' 3import { 4 type AppBskyFeedDefs, 5 type AppBskyFeedPost, 6 type AppBskyFeedThreadgate, 7 type RichText as RichTextAPI, 8} from '@atproto/api' 9import {msg, plural} from '@lingui/core/macro' 10import {useLingui} from '@lingui/react' 11 12import {CountWheel} from '#/lib/custom-animations/CountWheel' 13import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' 14import {useHaptics} from '#/lib/haptics' 15import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 16import {type Shadow} from '#/state/cache/types' 17import {useFeedFeedbackContext} from '#/state/feed-feedback' 18import { 19 usePostLikeMutationQueue, 20 usePostRepostMutationQueue, 21} from '#/state/queries/post' 22import {useRequireAuth} from '#/state/session' 23import { 24 ProgressGuideAction, 25 useProgressGuideControls, 26} from '#/state/shell/progress-guide' 27import * as Toast from '#/view/com/util/Toast' 28import {atoms as a, useBreakpoints} from '#/alf' 29import {Reply as Bubble} from '#/components/icons/Reply' 30import {useFormatPostStatCount} from '#/components/PostControls/util' 31import * as Skele from '#/components/Skeleton' 32import {useAnalytics} from '#/analytics' 33import {BookmarkButton} from './BookmarkButton' 34import { 35 PostControlButton, 36 PostControlButtonIcon, 37 PostControlButtonText, 38} from './PostControlButton' 39import {PostMenuButton} from './PostMenu' 40import {RepostButton} from './RepostButton' 41import {ShareMenuButton} from './ShareMenu' 42 43let PostControls = ({ 44 big, 45 post, 46 record, 47 richText, 48 feedContext, 49 reqId, 50 style, 51 onPressReply, 52 onPostReply, 53 logContext, 54 threadgateRecord, 55 onShowLess, 56 viaRepost, 57 variant, 58}: { 59 big?: boolean 60 post: Shadow<AppBskyFeedDefs.PostView> 61 record: AppBskyFeedPost.Record 62 richText: RichTextAPI 63 feedContext?: string | undefined 64 reqId?: string | undefined 65 style?: StyleProp<ViewStyle> 66 onPressReply: () => void 67 onPostReply?: (postUri: string | undefined) => void 68 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 69 threadgateRecord?: AppBskyFeedThreadgate.Record 70 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 71 viaRepost?: {uri: string; cid: string} 72 variant?: 'compact' | 'normal' | 'large' 73}): React.ReactNode => { 74 const ax = useAnalytics() 75 const {_} = useLingui() 76 const {openComposer} = useOpenComposer() 77 const {feedDescriptor} = useFeedFeedbackContext() 78 const [queueLike, queueUnlike] = usePostLikeMutationQueue( 79 post, 80 viaRepost, 81 feedDescriptor, 82 logContext, 83 ) 84 const [queueRepost, queueUnrepost] = usePostRepostMutationQueue( 85 post, 86 viaRepost, 87 feedDescriptor, 88 logContext, 89 ) 90 const requireAuth = useRequireAuth() 91 const {sendInteraction} = useFeedFeedbackContext() 92 const {captureAction} = useProgressGuideControls() 93 const playHaptic = useHaptics() 94 const isBlocked = Boolean( 95 post.author.viewer?.blocking || 96 post.author.viewer?.blockedBy || 97 post.author.viewer?.blockingByList, 98 ) 99 const replyDisabled = post.viewer?.replyDisabled 100 const {gtPhone} = useBreakpoints() 101 const formatPostStatCount = useFormatPostStatCount() 102 103 const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false) 104 105 const onPressToggleLike = async () => { 106 if (isBlocked) { 107 Toast.show( 108 _(msg`Cannot interact with a blocked user`), 109 'exclamation-circle', 110 ) 111 return 112 } 113 114 try { 115 setHasLikeIconBeenToggled(true) 116 if (!post.viewer?.like) { 117 playHaptic('Light') 118 sendInteraction({ 119 item: post.uri, 120 event: 'app.bsky.feed.defs#interactionLike', 121 feedContext, 122 reqId, 123 }) 124 captureAction(ProgressGuideAction.Like) 125 await queueLike() 126 } else { 127 await queueUnlike() 128 } 129 } catch (e: any) { 130 if (e?.name !== 'AbortError') { 131 throw e 132 } 133 } 134 } 135 136 const onRepost = async () => { 137 if (isBlocked) { 138 Toast.show( 139 _(msg`Cannot interact with a blocked user`), 140 'exclamation-circle', 141 ) 142 return 143 } 144 145 try { 146 if (!post.viewer?.repost) { 147 sendInteraction({ 148 item: post.uri, 149 event: 'app.bsky.feed.defs#interactionRepost', 150 feedContext, 151 reqId, 152 }) 153 await queueRepost() 154 } else { 155 await queueUnrepost() 156 } 157 } catch (e: any) { 158 if (e?.name !== 'AbortError') { 159 throw e 160 } 161 } 162 } 163 164 const onQuote = () => { 165 if (isBlocked) { 166 Toast.show( 167 _(msg`Cannot interact with a blocked user`), 168 'exclamation-circle', 169 ) 170 return 171 } 172 173 sendInteraction({ 174 item: post.uri, 175 event: 'app.bsky.feed.defs#interactionQuote', 176 feedContext, 177 reqId, 178 }) 179 ax.metric('post:clickQuotePost', { 180 uri: post.uri, 181 authorDid: post.author.did, 182 logContext, 183 feedDescriptor, 184 }) 185 openComposer({ 186 quote: post, 187 onPost: onPostReply, 188 logContext: 'QuotePost', 189 }) 190 } 191 192 const onShare = () => { 193 sendInteraction({ 194 item: post.uri, 195 event: 'app.bsky.feed.defs#interactionShare', 196 feedContext, 197 reqId, 198 }) 199 } 200 201 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({ 202 variant, 203 big, 204 gtPhone, 205 }) 206 207 return ( 208 <View 209 style={[ 210 a.flex_row, 211 a.justify_between, 212 a.align_center, 213 !big && a.pt_2xs, 214 a.gap_md, 215 style, 216 ]}> 217 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}> 218 <View 219 style={[ 220 a.flex_1, 221 a.align_start, 222 {marginLeft: big ? -2 : -6}, 223 replyDisabled ? {opacity: 0.6} : undefined, 224 ]}> 225 <PostControlButton 226 testID="replyBtn" 227 onPress={ 228 !replyDisabled 229 ? () => 230 requireAuth(() => { 231 ax.metric('post:clickReply', { 232 uri: post.uri, 233 authorDid: post.author.did, 234 logContext, 235 feedDescriptor, 236 }) 237 onPressReply() 238 }) 239 : undefined 240 } 241 label={_( 242 msg({ 243 message: `Reply (${plural(post.replyCount || 0, { 244 one: '# reply', 245 other: '# replies', 246 })})`, 247 comment: 248 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 249 }), 250 )} 251 big={big}> 252 <PostControlButtonIcon icon={Bubble} /> 253 {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && ( 254 <PostControlButtonText> 255 {formatPostStatCount(post.replyCount)} 256 </PostControlButtonText> 257 )} 258 </PostControlButton> 259 </View> 260 <View style={[a.flex_1, a.align_start]}> 261 <RepostButton 262 isReposted={!!post.viewer?.repost} 263 repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)} 264 onRepost={onRepost} 265 onQuote={onQuote} 266 big={big} 267 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 268 /> 269 </View> 270 <View style={[a.flex_1, a.align_start]}> 271 <PostControlButton 272 testID="likeBtn" 273 big={big} 274 onPress={() => requireAuth(() => onPressToggleLike())} 275 label={ 276 post.viewer?.like 277 ? _( 278 msg({ 279 message: `Unlike (${plural(post.likeCount || 0, { 280 one: '# like', 281 other: '# likes', 282 })})`, 283 comment: 284 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', 285 }), 286 ) 287 : _( 288 msg({ 289 message: `Like (${plural(post.likeCount || 0, { 290 one: '# like', 291 other: '# likes', 292 })})`, 293 comment: 294 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', 295 }), 296 ) 297 }> 298 <AnimatedLikeIcon 299 isLiked={Boolean(post.viewer?.like)} 300 big={big} 301 hasBeenToggled={hasLikeIconBeenToggled} 302 /> 303 <CountWheel 304 likeCount={post.likeCount ?? 0} 305 big={big} 306 isLiked={Boolean(post.viewer?.like)} 307 hasBeenToggled={hasLikeIconBeenToggled} 308 /> 309 </PostControlButton> 310 </View> 311 {/* Spacer! */} 312 <View /> 313 </View> 314 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}> 315 <BookmarkButton 316 post={post} 317 big={big} 318 logContext={logContext} 319 hitSlop={{ 320 right: secondaryControlSpacingStyles.gap / 2, 321 }} 322 /> 323 <ShareMenuButton 324 testID="postShareBtn" 325 post={post} 326 big={big} 327 record={record} 328 richText={richText} 329 timestamp={post.indexedAt} 330 threadgateRecord={threadgateRecord} 331 onShare={onShare} 332 hitSlop={{ 333 left: secondaryControlSpacingStyles.gap / 2, 334 right: secondaryControlSpacingStyles.gap / 2, 335 }} 336 logContext={logContext} 337 /> 338 <PostMenuButton 339 testID="postDropdownBtn" 340 post={post} 341 postFeedContext={feedContext} 342 postReqId={reqId} 343 big={big} 344 record={record} 345 richText={richText} 346 timestamp={post.indexedAt} 347 threadgateRecord={threadgateRecord} 348 onShowLess={onShowLess} 349 hitSlop={{ 350 left: secondaryControlSpacingStyles.gap / 2, 351 }} 352 logContext={logContext} 353 /> 354 </View> 355 </View> 356 ) 357} 358PostControls = memo(PostControls) 359export {PostControls} 360 361export function PostControlsSkeleton({ 362 big, 363 style, 364 variant, 365}: { 366 big?: boolean 367 style?: StyleProp<ViewStyle> 368 variant?: 'compact' | 'normal' | 'large' 369}) { 370 const {gtPhone} = useBreakpoints() 371 372 const rowHeight = big ? 32 : 28 373 const padding = 4 374 const size = rowHeight - padding * 2 375 376 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({ 377 variant, 378 big, 379 gtPhone, 380 }) 381 382 const itemStyles = { 383 padding, 384 } 385 386 return ( 387 <Skele.Row 388 style={[a.flex_row, a.justify_between, a.align_center, a.gap_md, style]}> 389 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}> 390 <View 391 style={[itemStyles, a.flex_1, a.align_start, {marginLeft: -padding}]}> 392 <Skele.Pill blend size={size} /> 393 </View> 394 395 <View style={[itemStyles, a.flex_1, a.align_start]}> 396 <Skele.Pill blend size={size} /> 397 </View> 398 399 <View style={[itemStyles, a.flex_1, a.align_start]}> 400 <Skele.Pill blend size={size} /> 401 </View> 402 </View> 403 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}> 404 <View style={itemStyles}> 405 <Skele.Circle blend size={size} /> 406 </View> 407 <View style={itemStyles}> 408 <Skele.Circle blend size={size} /> 409 </View> 410 <View style={itemStyles}> 411 <Skele.Circle blend size={size} /> 412 </View> 413 </View> 414 </Skele.Row> 415 ) 416} 417 418function useSecondaryControlSpacingStyles({ 419 variant, 420 big, 421 gtPhone, 422}: { 423 variant?: 'compact' | 'normal' | 'large' 424 big?: boolean 425 gtPhone: boolean 426}) { 427 return useMemo(() => { 428 let gap = 0 // default, we want `gap` to be defined on the resulting object 429 if (variant !== 'compact') gap = a.gap_xs.gap 430 if (big || gtPhone) gap = a.gap_sm.gap 431 return {gap} 432 }, [variant, big, gtPhone]) 433}