Bluesky app fork with some witchin' additions 馃挮
at feat/tealfm 448 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 }) 197 } 198 199 const onShare = () => { 200 sendInteraction({ 201 item: post.uri, 202 event: 'app.bsky.feed.defs#interactionShare', 203 feedContext, 204 reqId, 205 }) 206 } 207 208 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({ 209 variant, 210 big, 211 gtPhone, 212 }) 213 214 return ( 215 <View 216 style={[ 217 a.flex_row, 218 a.justify_between, 219 a.align_center, 220 !big && a.pt_2xs, 221 a.gap_md, 222 style, 223 ]}> 224 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}> 225 <View 226 style={[ 227 a.flex_1, 228 a.align_start, 229 {marginLeft: big ? -2 : -6}, 230 replyDisabled ? {opacity: 0.6} : undefined, 231 ]}> 232 <PostControlButton 233 testID="replyBtn" 234 onPress={ 235 !replyDisabled 236 ? () => 237 requireAuth(() => { 238 ax.metric('post:clickReply', { 239 uri: post.uri, 240 authorDid: post.author.did, 241 logContext, 242 feedDescriptor, 243 }) 244 onPressReply() 245 }) 246 : undefined 247 } 248 label={_( 249 msg({ 250 message: `Reply (${plural(post.replyCount || 0, { 251 one: '# reply', 252 other: '# replies', 253 })})`, 254 comment: 255 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 256 }), 257 )} 258 big={big}> 259 <PostControlButtonIcon icon={Bubble} /> 260 {typeof post.replyCount !== 'undefined' && 261 post.replyCount > 0 && 262 !disableReplyMetrics && ( 263 <PostControlButtonText> 264 {formatPostStatCount(post.replyCount)} 265 </PostControlButtonText> 266 )} 267 </PostControlButton> 268 </View> 269 <View style={[a.flex_1, a.align_start]}> 270 <RepostButton 271 isReposted={!!post.viewer?.repost} 272 repostCount={ 273 !disableRepostsMetrics 274 ? (post.repostCount ?? 0) + (post.quoteCount ?? 0) 275 : 0 276 } 277 onRepost={onRepost} 278 onQuote={onQuote} 279 big={big} 280 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 281 /> 282 </View> 283 <View style={[a.flex_1, a.align_start]}> 284 <PostControlButton 285 testID="likeBtn" 286 big={big} 287 onPress={() => requireAuth(() => onPressToggleLike())} 288 label={ 289 post.viewer?.like 290 ? _( 291 msg({ 292 message: `Unlike (${plural(post.likeCount || 0, { 293 one: '# like', 294 other: '# likes', 295 })})`, 296 comment: 297 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', 298 }), 299 ) 300 : _( 301 msg({ 302 message: `Like (${plural(post.likeCount || 0, { 303 one: '# like', 304 other: '# likes', 305 })})`, 306 comment: 307 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', 308 }), 309 ) 310 }> 311 <AnimatedLikeIcon 312 isLiked={Boolean(post.viewer?.like)} 313 big={big} 314 hasBeenToggled={hasLikeIconBeenToggled} 315 /> 316 {!disableLikesMetrics ? ( 317 <CountWheel 318 likeCount={post.likeCount ?? 0} 319 big={big} 320 isLiked={Boolean(post.viewer?.like)} 321 hasBeenToggled={hasLikeIconBeenToggled} 322 /> 323 ) : null} 324 </PostControlButton> 325 </View> 326 {/* Spacer! */} 327 <View /> 328 </View> 329 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}> 330 <BookmarkButton 331 post={post} 332 big={big} 333 logContext={logContext} 334 hitSlop={{ 335 right: secondaryControlSpacingStyles.gap / 2, 336 }} 337 /> 338 <ShareMenuButton 339 testID="postShareBtn" 340 post={post} 341 big={big} 342 record={record} 343 richText={richText} 344 timestamp={post.indexedAt} 345 threadgateRecord={threadgateRecord} 346 onShare={onShare} 347 hitSlop={{ 348 left: secondaryControlSpacingStyles.gap / 2, 349 right: secondaryControlSpacingStyles.gap / 2, 350 }} 351 logContext={logContext} 352 /> 353 <PostMenuButton 354 testID="postDropdownBtn" 355 post={post} 356 postFeedContext={feedContext} 357 postReqId={reqId} 358 big={big} 359 record={record} 360 richText={richText} 361 timestamp={post.indexedAt} 362 threadgateRecord={threadgateRecord} 363 onShowLess={onShowLess} 364 hitSlop={{ 365 left: secondaryControlSpacingStyles.gap / 2, 366 }} 367 logContext={logContext} 368 /> 369 </View> 370 </View> 371 ) 372} 373PostControls = memo(PostControls) 374export {PostControls} 375 376export function PostControlsSkeleton({ 377 big, 378 style, 379 variant, 380}: { 381 big?: boolean 382 style?: StyleProp<ViewStyle> 383 variant?: 'compact' | 'normal' | 'large' 384}) { 385 const {gtPhone} = useBreakpoints() 386 387 const rowHeight = big ? 32 : 28 388 const padding = 4 389 const size = rowHeight - padding * 2 390 391 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({ 392 variant, 393 big, 394 gtPhone, 395 }) 396 397 const itemStyles = { 398 padding, 399 } 400 401 return ( 402 <Skele.Row 403 style={[a.flex_row, a.justify_between, a.align_center, a.gap_md, style]}> 404 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}> 405 <View 406 style={[itemStyles, a.flex_1, a.align_start, {marginLeft: -padding}]}> 407 <Skele.Pill blend size={size} /> 408 </View> 409 410 <View style={[itemStyles, a.flex_1, a.align_start]}> 411 <Skele.Pill blend size={size} /> 412 </View> 413 414 <View style={[itemStyles, a.flex_1, a.align_start]}> 415 <Skele.Pill blend size={size} /> 416 </View> 417 </View> 418 <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}> 419 <View style={itemStyles}> 420 <Skele.Circle blend size={size} /> 421 </View> 422 <View style={itemStyles}> 423 <Skele.Circle blend size={size} /> 424 </View> 425 <View style={itemStyles}> 426 <Skele.Circle blend size={size} /> 427 </View> 428 </View> 429 </Skele.Row> 430 ) 431} 432 433function useSecondaryControlSpacingStyles({ 434 variant, 435 big, 436 gtPhone, 437}: { 438 variant?: 'compact' | 'normal' | 'large' 439 big?: boolean 440 gtPhone: boolean 441}) { 442 return useMemo(() => { 443 let gap = 0 // default, we want `gap` to be defined on the resulting object 444 if (variant !== 'compact') gap = a.gap_xs.gap 445 if (big || gtPhone) gap = a.gap_sm.gap 446 return {gap} 447 }, [variant, big, gtPhone]) 448}