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