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

Configure Feed

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

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