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

Configure Feed

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

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