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