this repo has no description
0
fork

Configure Feed

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

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