Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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