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