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