my fork of the bluesky client

Pinned posts (#5055)

* add to dropdown menu

* use normal profile mutation hook

* add pin as reason

* request pins

* shadow update

* rm logs

* get prev pinned from getProfile

* fix toasts

* invalidate after appview ready

* don't mutate params

* rm log

* use checkCommited rather than manual whenAppViewReady

* move to mutation

* even more optimistic optimistic update

* allow pins in `posts_and_author_threads`

* update @atproto/api

* add reasonPin type

* fix strange type error in unrelated query

* another missing type

authored by samuel.fm and committed by

GitHub 4b5d6e6e f68b1521

+212 -35
+1 -1
package.json
··· 53 53 "icons:optimize": "svgo -f ./assets/icons" 54 54 }, 55 55 "dependencies": { 56 - "@atproto/api": "^0.13.7", 56 + "@atproto/api": "^0.13.8", 57 57 "@braintree/sanitize-url": "^6.0.2", 58 58 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", 59 59 "@emoji-mart/react": "^1.1.1",
+10 -3
src/lib/api/feed/author.ts
··· 8 8 9 9 export class AuthorFeedAPI implements FeedAPI { 10 10 agent: BskyAgent 11 - params: GetAuthorFeed.QueryParams 11 + _params: GetAuthorFeed.QueryParams 12 12 13 13 constructor({ 14 14 agent, ··· 18 18 feedParams: GetAuthorFeed.QueryParams 19 19 }) { 20 20 this.agent = agent 21 - this.params = feedParams 21 + this._params = feedParams 22 + } 23 + 24 + get params() { 25 + const params = {...this._params} 26 + params.includePins = params.filter !== 'posts_with_media' 27 + return params 22 28 } 23 29 24 30 async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { ··· 57 63 return feed.filter(post => { 58 64 const isReply = post.reply 59 65 const isRepost = AppBskyFeedDefs.isReasonRepost(post.reason) 66 + const isPin = AppBskyFeedDefs.isReasonPin(post.reason) 60 67 if (!isReply) return true 61 - if (isRepost) return true 68 + if (isRepost || isPin) return true 62 69 return isReply && isAuthorReplyChain(this.params.actor, post, feed) 63 70 }) 64 71 }
+2
src/state/cache/post-shadow.ts
··· 21 21 repostUri: string | undefined 22 22 isDeleted: boolean 23 23 embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined 24 + pinned: boolean 24 25 } 25 26 26 27 export const POST_TOMBSTONE = Symbol('PostTombstone') ··· 113 114 ...(post.viewer || {}), 114 115 like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like, 115 116 repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost, 117 + pinned: 'pinned' in shadow ? shadow.pinned : post.viewer?.pinned, 116 118 }, 117 119 }) 118 120 }
+87
src/state/queries/pinned-post.ts
··· 1 + import {msg} from '@lingui/macro' 2 + import {useLingui} from '@lingui/react' 3 + import {useMutation, useQueryClient} from '@tanstack/react-query' 4 + 5 + import {logger} from '#/logger' 6 + import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' 7 + import * as Toast from '#/view/com/util/Toast' 8 + import {updatePostShadow} from '../cache/post-shadow' 9 + import {useAgent, useSession} from '../session' 10 + import {useProfileUpdateMutation} from './profile' 11 + 12 + export function usePinnedPostMutation() { 13 + const {_} = useLingui() 14 + const {currentAccount} = useSession() 15 + const agent = useAgent() 16 + const queryClient = useQueryClient() 17 + const {mutateAsync: profileUpdateMutate} = useProfileUpdateMutation() 18 + 19 + return useMutation({ 20 + mutationFn: async ({ 21 + postUri, 22 + postCid, 23 + action, 24 + }: { 25 + postUri: string 26 + postCid: string 27 + action: 'pin' | 'unpin' 28 + }) => { 29 + const pinCurrentPost = action === 'pin' 30 + let prevPinnedPost: string | undefined 31 + try { 32 + updatePostShadow(queryClient, postUri, {pinned: pinCurrentPost}) 33 + 34 + // get the currently pinned post so we can optimistically remove the pin from it 35 + if (!currentAccount) throw new Error('Not logged in') 36 + const {data: profile} = await agent.getProfile({ 37 + actor: currentAccount.did, 38 + }) 39 + prevPinnedPost = profile.pinnedPost?.uri 40 + if (prevPinnedPost && prevPinnedPost !== postUri) { 41 + updatePostShadow(queryClient, prevPinnedPost, {pinned: false}) 42 + } 43 + 44 + await profileUpdateMutate({ 45 + profile, 46 + updates: existing => { 47 + existing.pinnedPost = pinCurrentPost 48 + ? {uri: postUri, cid: postCid} 49 + : undefined 50 + return existing 51 + }, 52 + checkCommitted: res => 53 + pinCurrentPost 54 + ? res.data.pinnedPost?.uri === postUri 55 + : !res.data.pinnedPost, 56 + }) 57 + 58 + if (pinCurrentPost) { 59 + Toast.show(_(msg`Post pinned`)) 60 + } else { 61 + Toast.show(_(msg`Post unpinned`)) 62 + } 63 + 64 + queryClient.invalidateQueries({ 65 + queryKey: FEED_RQKEY( 66 + `author|${currentAccount.did}|posts_and_author_threads`, 67 + ), 68 + }) 69 + queryClient.invalidateQueries({ 70 + queryKey: FEED_RQKEY( 71 + `author|${currentAccount.did}|posts_with_replies`, 72 + ), 73 + }) 74 + } catch (e: any) { 75 + Toast.show(_(msg`Failed to pin post`)) 76 + logger.error('Failed to pin post', {message: String(e)}) 77 + // revert optimistic update 78 + updatePostShadow(queryClient, postUri, { 79 + pinned: !pinCurrentPost, 80 + }) 81 + if (prevPinnedPost && prevPinnedPost !== postUri) { 82 + updatePostShadow(queryClient, prevPinnedPost, {pinned: true}) 83 + } 84 + } 85 + }, 86 + }) 87 + }
+1
src/state/queries/post-feed.ts
··· 91 91 feedContext: string | undefined 92 92 reason?: 93 93 | AppBskyFeedDefs.ReasonRepost 94 + | AppBskyFeedDefs.ReasonPin 94 95 | ReasonFeedSource 95 96 | {[k: string]: unknown; $type: string} 96 97 }
+3
src/state/queries/profile.ts
··· 159 159 } else { 160 160 existing.displayName = updates.displayName 161 161 existing.description = updates.description 162 + if ('pinnedPost' in updates) { 163 + existing.pinnedPost = updates.pinnedPost 164 + } 162 165 } 163 166 if (newUserAvatarPromise) { 164 167 const res = await newUserAvatarPromise
+5 -6
src/state/queries/suggested-follows.ts
··· 105 105 106 106 export function useSuggestedFollowsByActorQuery({did}: {did: string}) { 107 107 const agent = useAgent() 108 - return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({ 108 + return useQuery({ 109 109 queryKey: suggestedFollowsByActorQueryKey(did), 110 110 queryFn: async () => { 111 111 const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ 112 112 actor: did, 113 113 }) 114 - const data = res.data.isFallback ? {suggestions: []} : res.data 115 - data.suggestions = data.suggestions.filter(profile => { 116 - return !profile.viewer?.following 117 - }) 118 - return data 114 + const suggestions = res.data.isFallback 115 + ? [] 116 + : res.data.suggestions.filter(profile => !profile.viewer?.following) 117 + return {suggestions} 119 118 }, 120 119 }) 121 120 }
+19 -2
src/view/com/posts/FeedItem.tsx
··· 38 38 import {Text} from '#/view/com/util/text/Text' 39 39 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 40 40 import {atoms as a} from '#/alf' 41 - import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' 41 + import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 42 + import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' 42 43 import {ContentHider} from '#/components/moderation/ContentHider' 43 44 import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 44 45 import {PostAlerts} from '#/components/moderation/PostAlerts' ··· 52 53 record: AppBskyFeedPost.Record 53 54 reason: 54 55 | AppBskyFeedDefs.ReasonRepost 56 + | AppBskyFeedDefs.ReasonPin 55 57 | ReasonFeedSource 56 58 | {[k: string]: unknown; $type: string} 57 59 | undefined ··· 295 297 ) 296 298 } 297 299 onBeforePress={onOpenReposter}> 298 - <Repost 300 + <RepostIcon 299 301 style={{color: pal.colors.textLight, marginRight: 3}} 300 302 width={14} 301 303 height={14} ··· 337 339 )} 338 340 </Text> 339 341 </Link> 342 + ) : AppBskyFeedDefs.isReasonPin(reason) ? ( 343 + <View style={styles.includeReason}> 344 + <PinIcon 345 + style={{color: pal.colors.textLight, marginRight: 3}} 346 + width={14} 347 + height={14} 348 + /> 349 + <Text 350 + type="sm-bold" 351 + style={pal.textLight} 352 + lineHeight={1.2} 353 + numberOfLines={1}> 354 + <Trans>Pinned</Trans> 355 + </Text> 356 + </View> 340 357 ) : null} 341 358 </View> 342 359 </View>
+51 -11
src/view/com/util/forms/PostDropdownBtn.tsx
··· 1 - import React, {memo} from 'react' 1 + import React, {memo, useCallback} from 'react' 2 2 import { 3 3 Platform, 4 4 Pressable, ··· 18 18 import {useLingui} from '@lingui/react' 19 19 import {useNavigation} from '@react-navigation/native' 20 20 21 + import {getCurrentRoute} from '#/lib/routes/helpers' 21 22 import {makeProfileLink} from '#/lib/routes/links' 22 23 import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' 24 + import {shareUrl} from '#/lib/sharing' 23 25 import {richTextToString} from '#/lib/strings/rich-text-helpers' 26 + import {toShareUrl} from '#/lib/strings/url-helpers' 27 + import {useTheme} from '#/lib/ThemeContext' 24 28 import {getTranslatorLink} from '#/locale/helpers' 25 29 import {logger} from '#/logger' 26 30 import {isWeb} from '#/platform/detection' ··· 29 33 import {useLanguagePrefs} from '#/state/preferences' 30 34 import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' 31 35 import {useOpenLink} from '#/state/preferences/in-app-browser' 36 + import {usePinnedPostMutation} from '#/state/queries/pinned-post' 32 37 import { 33 38 usePostDeleteMutation, 34 39 useThreadMuteMutationQueue, ··· 38 43 import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' 39 44 import {useSession} from '#/state/session' 40 45 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 41 - import {getCurrentRoute} from 'lib/routes/helpers' 42 - import {shareUrl} from 'lib/sharing' 43 - import {toShareUrl} from 'lib/strings/url-helpers' 44 - import {useTheme} from 'lib/ThemeContext' 45 46 import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf' 46 47 import {useDialogControl} from '#/components/Dialog' 47 48 import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' ··· 65 66 import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 66 67 import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 67 68 import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' 69 + import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' 68 70 import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' 69 71 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 70 72 import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' ··· 106 108 const {_} = useLingui() 107 109 const defaultCtrlColor = theme.palette.default.postCtrl 108 110 const langPrefs = useLanguagePrefs() 109 - const postDeleteMutation = usePostDeleteMutation() 111 + const {mutateAsync: deletePostMutate} = usePostDeleteMutation() 112 + const {mutateAsync: pinPostMutate, isPending: isPinPending} = 113 + usePinnedPostMutation() 110 114 const hiddenPosts = useHiddenPosts() 111 115 const {hidePost} = useHiddenPostsApi() 112 116 const feedFeedback = useFeedFeedbackContext() ··· 149 153 threadgateRecord, 150 154 }) 151 155 const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri) 156 + const isPinned = post.viewer?.pinned 152 157 153 - const {mutateAsync: toggleQuoteDetachment, isPending} = 158 + const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} = 154 159 useToggleQuoteDetachmentMutation() 155 160 156 161 const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ ··· 169 174 ) 170 175 171 176 const onDeletePost = React.useCallback(() => { 172 - postDeleteMutation.mutateAsync({uri: postUri}).then( 177 + deletePostMutate({uri: postUri}).then( 173 178 () => { 174 179 Toast.show(_(msg`Post deleted`)) 175 180 ··· 197 202 }, [ 198 203 navigation, 199 204 postUri, 200 - postDeleteMutation, 205 + deletePostMutate, 201 206 postAuthor, 202 207 currentAccount, 203 208 isAuthor, ··· 343 348 canHideReplyForEveryone, 344 349 toggleReplyVisibility, 345 350 ]) 351 + 352 + const onPressPin = useCallback(() => { 353 + pinPostMutate({ 354 + postUri, 355 + postCid, 356 + action: isPinned ? 'unpin' : 'pin', 357 + }) 358 + }, [isPinned, pinPostMutate, postCid, postUri]) 346 359 347 360 return ( 348 361 <EventStopper onKeyDown={false}> ··· 372 385 </Menu.Trigger> 373 386 374 387 <Menu.Outer> 388 + {isAuthor && ( 389 + <> 390 + <Menu.Group> 391 + <Menu.Item 392 + testID="pinPostBtn" 393 + label={ 394 + isPinned 395 + ? _(msg`Unpin from profile`) 396 + : _(msg`Pin to your profile`) 397 + } 398 + disabled={isPinPending} 399 + onPress={onPressPin}> 400 + <Menu.ItemText> 401 + {isPinned 402 + ? _(msg`Unpin from profile`) 403 + : _(msg`Pin to your profile`)} 404 + </Menu.ItemText> 405 + <Menu.ItemIcon 406 + icon={isPinPending ? Loader : PinIcon} 407 + position="right" 408 + /> 409 + </Menu.Item> 410 + </Menu.Group> 411 + <Menu.Divider /> 412 + </> 413 + )} 414 + 375 415 <Menu.Group> 376 416 {(!hideInPWI || hasSession) && ( 377 417 <> ··· 536 576 537 577 {canDetachQuote && ( 538 578 <Menu.Item 539 - disabled={isPending} 579 + disabled={isDetachPending} 540 580 testID="postDropdownHideBtn" 541 581 label={ 542 582 quoteEmbed.isDetached ··· 555 595 </Menu.ItemText> 556 596 <Menu.ItemIcon 557 597 icon={ 558 - isPending 598 + isDetachPending 559 599 ? Loader 560 600 : quoteEmbed.isDetached 561 601 ? Eye
+33 -12
yarn.lock
··· 85 85 multiformats "^9.9.0" 86 86 tlds "^1.234.0" 87 87 88 - "@atproto/api@^0.13.7": 89 - version "0.13.7" 90 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.7.tgz#072eba2025d5251505f17b0b5d2de33749ea5ee4" 91 - integrity sha512-41kSLmFWDbuPOenb52WRq1lnBkSZrL+X29tWcvEt6SZXK4xBoKAalw1MjF+oabhzff12iMtNaNvmmt2fu1L+cw== 88 + "@atproto/api@^0.13.8": 89 + version "0.13.8" 90 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.8.tgz#44aa4992442812604bccf9eebe4d1db9ed64c179" 91 + integrity sha512-1RlvMg8iAT5k3F0U3549ct9+jXthlXtfFXIfTXLyXXFe9Exfvmr7ZJ1ra41vU1nXGsoouCoTxj7kdzC4MY8JZg== 92 92 dependencies: 93 - "@atproto/common-web" "^0.3.0" 94 - "@atproto/lexicon" "^0.4.1" 93 + "@atproto/common-web" "^0.3.1" 94 + "@atproto/lexicon" "^0.4.2" 95 95 "@atproto/syntax" "^0.3.0" 96 - "@atproto/xrpc" "^0.6.2" 96 + "@atproto/xrpc" "^0.6.3" 97 97 await-lock "^2.2.2" 98 98 multiformats "^9.9.0" 99 99 tlds "^1.234.0" ··· 179 179 uint8arrays "3.0.0" 180 180 zod "^3.21.4" 181 181 182 + "@atproto/common-web@^0.3.1": 183 + version "0.3.1" 184 + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.3.1.tgz#86f8efb10a4b9073839cee914c6c08a664917cc4" 185 + integrity sha512-N7wiTnus5vAr+lT//0y8m/FaHHLJ9LpGuEwkwDAeV3LCiPif4m/FS8x/QOYrx1PdZQwKso95RAPzCGWQBH5j6Q== 186 + dependencies: 187 + graphemer "^1.4.0" 188 + multiformats "^9.9.0" 189 + uint8arrays "3.0.0" 190 + zod "^3.23.8" 191 + 182 192 "@atproto/common@0.1.0": 183 193 version "0.1.0" 184 194 resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210" ··· 287 297 integrity sha512-bzyr+/VHXLQWbumViX5L7h1NKQObfs8Z+XZJl43OUK8nYFUI4e/sW1IZKRNfw7Wvi5YVNK+J+yP3DWIBZhkCYA== 288 298 dependencies: 289 299 "@atproto/common-web" "^0.3.0" 300 + "@atproto/syntax" "^0.3.0" 301 + iso-datestring-validator "^2.2.2" 302 + multiformats "^9.9.0" 303 + zod "^3.23.8" 304 + 305 + "@atproto/lexicon@^0.4.2": 306 + version "0.4.2" 307 + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.2.tgz#fcc92cdb82ae248b034b172763d6dbadfb00a829" 308 + integrity sha512-CXoOkhcdF3XVUnR2oNgCs2ljWfo/8zUjxL5RIhJW/UNLp/FSl+KpF8Jm5fbk8Y/XXVPGRAsv9OYfxyU/14N/pw== 309 + dependencies: 310 + "@atproto/common-web" "^0.3.1" 290 311 "@atproto/syntax" "^0.3.0" 291 312 iso-datestring-validator "^2.2.2" 292 313 multiformats "^9.9.0" ··· 444 465 "@atproto/lexicon" "^0.4.1" 445 466 zod "^3.23.8" 446 467 447 - "@atproto/xrpc@^0.6.2": 448 - version "0.6.2" 449 - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.2.tgz#634228a7e533de01bda2214837d11574fdadad55" 450 - integrity sha512-as/gb08xJb02HAGNrSQSumCe10WnOAcnM6bR6KMatQyQJuEu7OY6ZDSTM/4HfjjoxsNqdvPmbYuoUab1bKTNlA== 468 + "@atproto/xrpc@^0.6.3": 469 + version "0.6.3" 470 + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.3.tgz#5942fc24644ad182b913af526efaa06a43d89478" 471 + integrity sha512-S3tRvOdA9amPkKLll3rc4vphlDitLrkN5TwWh5Tu/jzk7mnobVVE3akYgICV9XCNHKjWM+IAPxFFI2qi+VW6nQ== 451 472 dependencies: 452 - "@atproto/lexicon" "^0.4.1" 473 + "@atproto/lexicon" "^0.4.2" 453 474 zod "^3.23.8" 454 475 455 476 "@aws-crypto/crc32@3.0.0":