Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

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