Bluesky app fork with some witchin' additions 馃挮
at main 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 {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 {useDisableLikesMetrics} from '#/state/preferences/disable-likes-metrics' 19import {useDisableReplyMetrics} from '#/state/preferences/disable-reply-metrics' 20import {useDisableRepostsMetrics} from '#/state/preferences/disable-reposts-metrics' 21import { 22 usePostLikeMutationQueue, 23 usePostRepostMutationQueue, 24} from '#/state/queries/post' 25import {useRequireAuth} from '#/state/session' 26import { 27 ProgressGuideAction, 28 useProgressGuideControls, 29} from '#/state/shell/progress-guide' 30import * as Toast from '#/view/com/util/Toast' 31import {atoms as a, useBreakpoints} from '#/alf' 32import {Reply as Bubble} from '#/components/icons/Reply' 33import {useFormatPostStatCount} from '#/components/PostControls/util' 34import * as Skele from '#/components/Skeleton' 35import {BookmarkButton} from './BookmarkButton' 36import { 37 PostControlButton, 38 PostControlButtonIcon, 39 PostControlButtonText, 40} from './PostControlButton' 41import {PostMenuButton} from './PostMenu' 42import {RepostButton} from './RepostButton' 43import {ShareMenuButton} from './ShareMenu' 44 45let PostControls = ({ 46 big, 47 post, 48 record, 49 richText, 50 feedContext, 51 reqId, 52 style, 53 onPressReply, 54 onPostReply, 55 logContext, 56 threadgateRecord, 57 onShowLess, 58 viaRepost, 59 variant, 60}: { 61 big?: boolean 62 post: Shadow<AppBskyFeedDefs.PostView> 63 record: AppBskyFeedPost.Record 64 richText: RichTextAPI 65 feedContext?: string | undefined 66 reqId?: string | undefined 67 style?: StyleProp<ViewStyle> 68 onPressReply: () => void 69 onPostReply?: (postUri: string | undefined) => void 70 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 71 threadgateRecord?: AppBskyFeedThreadgate.Record 72 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 73 viaRepost?: {uri: string; cid: string} 74 variant?: 'compact' | 'normal' | 'large' 75}): React.ReactNode => { 76 const {_} = useLingui() 77 const {openComposer} = useOpenComposer() 78 const {feedDescriptor} = useFeedFeedbackContext() 79 const [queueLike, queueUnlike] = usePostLikeMutationQueue( 80 post, 81 viaRepost, 82 feedDescriptor, 83 logContext, 84 ) 85 const [queueRepost, queueUnrepost] = usePostRepostMutationQueue( 86 post, 87 viaRepost, 88 feedDescriptor, 89 logContext, 90 ) 91 const requireAuth = useRequireAuth() 92 const {sendInteraction} = useFeedFeedbackContext() 93 const {captureAction} = useProgressGuideControls() 94 const playHaptic = useHaptics() 95 const isBlocked = Boolean( 96 post.author.viewer?.blocking || 97 post.author.viewer?.blockedBy || 98 post.author.viewer?.blockingByList, 99 ) 100 const replyDisabled = post.viewer?.replyDisabled 101 const {gtPhone} = useBreakpoints() 102 const formatPostStatCount = useFormatPostStatCount() 103 104 const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false) 105 106 // disable metrics 107 const disableLikesMetrics = useDisableLikesMetrics() 108 const disableRepostsMetrics = useDisableRepostsMetrics() 109 const disableReplyMetrics = useDisableReplyMetrics() 110 111 const onPressToggleLike = async () => { 112 if (isBlocked) { 113 Toast.show( 114 _(msg`Cannot interact with a blocked user`), 115 'exclamation-circle', 116 ) 117 return 118 } 119 120 try { 121 setHasLikeIconBeenToggled(true) 122 if (!post.viewer?.like) { 123 playHaptic('Light') 124 sendInteraction({ 125 item: post.uri, 126 event: 'app.bsky.feed.defs#interactionLike', 127 feedContext, 128 reqId, 129 }) 130 captureAction(ProgressGuideAction.Like) 131 await queueLike() 132 } else { 133 await queueUnlike() 134 } 135 } catch (e: any) { 136 if (e?.name !== 'AbortError') { 137 throw e 138 } 139 } 140 } 141 142 const onRepost = async () => { 143 if (isBlocked) { 144 Toast.show( 145 _(msg`Cannot interact with a blocked user`), 146 'exclamation-circle', 147 ) 148 return 149 } 150 151 try { 152 if (!post.viewer?.repost) { 153 sendInteraction({ 154 item: post.uri, 155 event: 'app.bsky.feed.defs#interactionRepost', 156 feedContext, 157 reqId, 158 }) 159 await queueRepost() 160 } else { 161 await queueUnrepost() 162 } 163 } catch (e: any) { 164 if (e?.name !== 'AbortError') { 165 throw e 166 } 167 } 168 } 169 170 const onQuote = () => { 171 if (isBlocked) { 172 Toast.show( 173 _(msg`Cannot interact with a blocked user`), 174 'exclamation-circle', 175 ) 176 return 177 } 178 179 sendInteraction({ 180 item: post.uri, 181 event: 'app.bsky.feed.defs#interactionQuote', 182 feedContext, 183 reqId, 184 }) 185 openComposer({ 186 quote: post, 187 onPost: onPostReply, 188 }) 189 } 190 191 const onShare = () => { 192 sendInteraction({ 193 item: post.uri, 194 event: 'app.bsky.feed.defs#interactionShare', 195 feedContext, 196 reqId, 197 }) 198 } 199 200 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({ 201 variant, 202 big, 203 gtPhone, 204 }) 205 206 return ( 207 <View 208 style={[ 209 a.flex_row, 210 a.justify_between, 211 a.align_center, 212 !big && a.pt_2xs, 213 a.gap_md, 214 style, 215 ]}> 216 <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}> 217 <View 218 style={[ 219 a.flex_1, 220 a.align_start, 221 {marginLeft: big ? -2 : -6}, 222 replyDisabled ? {opacity: 0.6} : undefined, 223 ]}> 224 <PostControlButton 225 testID="replyBtn" 226 onPress={ 227 !replyDisabled 228 ? () => requireAuth(() => onPressReply()) 229 : undefined 230 } 231 label={_( 232 msg({ 233 message: `Reply (${plural(post.replyCount || 0, { 234 one: '# reply', 235 other: '# replies', 236 })})`, 237 comment: 238 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 239 }), 240 )} 241 big={big}> 242 <PostControlButtonIcon icon={Bubble} /> 243 {typeof post.replyCount !== 'undefined' && 244 post.replyCount > 0 && 245 !disableReplyMetrics && ( 246 <PostControlButtonText> 247 {formatPostStatCount(post.replyCount)} 248 </PostControlButtonText> 249 )} 250 </PostControlButton> 251 </View> 252 <View style={[a.flex_1, a.align_start]}> 253 <RepostButton 254 isReposted={!!post.viewer?.repost} 255 repostCount={ 256 !disableRepostsMetrics 257 ? (post.repostCount ?? 0) + (post.quoteCount ?? 0) 258 : 0 259 } 260 onRepost={onRepost} 261 onQuote={onQuote} 262 big={big} 263 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 264 /> 265 </View> 266 <View style={[a.flex_1, a.align_start]}> 267 <PostControlButton 268 testID="likeBtn" 269 big={big} 270 onPress={() => requireAuth(() => onPressToggleLike())} 271 label={ 272 post.viewer?.like 273 ? _( 274 msg({ 275 message: `Unlike (${plural(post.likeCount || 0, { 276 one: '# like', 277 other: '# likes', 278 })})`, 279 comment: 280 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', 281 }), 282 ) 283 : _( 284 msg({ 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 }> 294 <AnimatedLikeIcon 295 isLiked={Boolean(post.viewer?.like)} 296 big={big} 297 hasBeenToggled={hasLikeIconBeenToggled} 298 /> 299 {!disableLikesMetrics ? ( 300 <CountWheel 301 likeCount={post.likeCount ?? 0} 302 big={big} 303 isLiked={Boolean(post.viewer?.like)} 304 hasBeenToggled={hasLikeIconBeenToggled} 305 /> 306 ) : null} 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 /> 335 <PostMenuButton 336 testID="postDropdownBtn" 337 post={post} 338 postFeedContext={feedContext} 339 postReqId={reqId} 340 big={big} 341 record={record} 342 richText={richText} 343 timestamp={post.indexedAt} 344 threadgateRecord={threadgateRecord} 345 onShowLess={onShowLess} 346 hitSlop={{ 347 left: secondaryControlSpacingStyles.gap / 2, 348 }} 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}