diff options
-rw-r--r-- | package.json | 2 | ||||
-rw-r--r-- | src/lib/api/feed/author.ts | 13 | ||||
-rw-r--r-- | src/state/cache/post-shadow.ts | 2 | ||||
-rw-r--r-- | src/state/queries/pinned-post.ts | 87 | ||||
-rw-r--r-- | src/state/queries/post-feed.ts | 1 | ||||
-rw-r--r-- | src/state/queries/profile.ts | 3 | ||||
-rw-r--r-- | src/state/queries/suggested-follows.ts | 11 | ||||
-rw-r--r-- | src/view/com/posts/FeedItem.tsx | 21 | ||||
-rw-r--r-- | src/view/com/util/forms/PostDropdownBtn.tsx | 62 | ||||
-rw-r--r-- | yarn.lock | 45 |
10 files changed, 212 insertions, 35 deletions
diff --git a/package.json b/package.json index ae92b96ab..4ab196b6a 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.13.7", + "@atproto/api": "^0.13.8", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@emoji-mart/react": "^1.1.1", diff --git a/src/lib/api/feed/author.ts b/src/lib/api/feed/author.ts index 56eff1881..50e6a447e 100644 --- a/src/lib/api/feed/author.ts +++ b/src/lib/api/feed/author.ts @@ -8,7 +8,7 @@ import {FeedAPI, FeedAPIResponse} from './types' export class AuthorFeedAPI implements FeedAPI { agent: BskyAgent - params: GetAuthorFeed.QueryParams + _params: GetAuthorFeed.QueryParams constructor({ agent, @@ -18,7 +18,13 @@ export class AuthorFeedAPI implements FeedAPI { feedParams: GetAuthorFeed.QueryParams }) { this.agent = agent - this.params = feedParams + this._params = feedParams + } + + get params() { + const params = {...this._params} + params.includePins = params.filter !== 'posts_with_media' + return params } async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> { @@ -57,8 +63,9 @@ export class AuthorFeedAPI implements FeedAPI { return feed.filter(post => { const isReply = post.reply const isRepost = AppBskyFeedDefs.isReasonRepost(post.reason) + const isPin = AppBskyFeedDefs.isReasonPin(post.reason) if (!isReply) return true - if (isRepost) return true + if (isRepost || isPin) return true return isReply && isAuthorReplyChain(this.params.actor, post, feed) }) } diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts index 65300a8ef..b456a76d9 100644 --- a/src/state/cache/post-shadow.ts +++ b/src/state/cache/post-shadow.ts @@ -21,6 +21,7 @@ export interface PostShadow { repostUri: string | undefined isDeleted: boolean embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined + pinned: boolean } export const POST_TOMBSTONE = Symbol('PostTombstone') @@ -113,6 +114,7 @@ function mergeShadow( ...(post.viewer || {}), like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like, repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost, + pinned: 'pinned' in shadow ? shadow.pinned : post.viewer?.pinned, }, }) } diff --git a/src/state/queries/pinned-post.ts b/src/state/queries/pinned-post.ts new file mode 100644 index 000000000..7e2c8ee79 --- /dev/null +++ b/src/state/queries/pinned-post.ts @@ -0,0 +1,87 @@ +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation, useQueryClient} from '@tanstack/react-query' + +import {logger} from '#/logger' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import * as Toast from '#/view/com/util/Toast' +import {updatePostShadow} from '../cache/post-shadow' +import {useAgent, useSession} from '../session' +import {useProfileUpdateMutation} from './profile' + +export function usePinnedPostMutation() { + const {_} = useLingui() + const {currentAccount} = useSession() + const agent = useAgent() + const queryClient = useQueryClient() + const {mutateAsync: profileUpdateMutate} = useProfileUpdateMutation() + + return useMutation({ + mutationFn: async ({ + postUri, + postCid, + action, + }: { + postUri: string + postCid: string + action: 'pin' | 'unpin' + }) => { + const pinCurrentPost = action === 'pin' + let prevPinnedPost: string | undefined + try { + updatePostShadow(queryClient, postUri, {pinned: pinCurrentPost}) + + // get the currently pinned post so we can optimistically remove the pin from it + if (!currentAccount) throw new Error('Not logged in') + const {data: profile} = await agent.getProfile({ + actor: currentAccount.did, + }) + prevPinnedPost = profile.pinnedPost?.uri + if (prevPinnedPost && prevPinnedPost !== postUri) { + updatePostShadow(queryClient, prevPinnedPost, {pinned: false}) + } + + await profileUpdateMutate({ + profile, + updates: existing => { + existing.pinnedPost = pinCurrentPost + ? {uri: postUri, cid: postCid} + : undefined + return existing + }, + checkCommitted: res => + pinCurrentPost + ? res.data.pinnedPost?.uri === postUri + : !res.data.pinnedPost, + }) + + if (pinCurrentPost) { + Toast.show(_(msg`Post pinned`)) + } else { + Toast.show(_(msg`Post unpinned`)) + } + + queryClient.invalidateQueries({ + queryKey: FEED_RQKEY( + `author|${currentAccount.did}|posts_and_author_threads`, + ), + }) + queryClient.invalidateQueries({ + queryKey: FEED_RQKEY( + `author|${currentAccount.did}|posts_with_replies`, + ), + }) + } catch (e: any) { + Toast.show(_(msg`Failed to pin post`)) + logger.error('Failed to pin post', {message: String(e)}) + // revert optimistic update + updatePostShadow(queryClient, postUri, { + pinned: !pinCurrentPost, + }) + if (prevPinnedPost && prevPinnedPost !== postUri) { + updatePostShadow(queryClient, prevPinnedPost, {pinned: true}) + } + } + }, + }) +} diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts index 07c5da81b..1785eb445 100644 --- a/src/state/queries/post-feed.ts +++ b/src/state/queries/post-feed.ts @@ -91,6 +91,7 @@ export interface FeedPostSlice { feedContext: string | undefined reason?: | AppBskyFeedDefs.ReasonRepost + | AppBskyFeedDefs.ReasonPin | ReasonFeedSource | {[k: string]: unknown; $type: string} } diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 78a142eea..3059d9efe 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -159,6 +159,9 @@ export function useProfileUpdateMutation() { } else { existing.displayName = updates.displayName existing.description = updates.description + if ('pinnedPost' in updates) { + existing.pinnedPost = updates.pinnedPost + } } if (newUserAvatarPromise) { const res = await newUserAvatarPromise diff --git a/src/state/queries/suggested-follows.ts b/src/state/queries/suggested-follows.ts index 5ae831704..07e16946e 100644 --- a/src/state/queries/suggested-follows.ts +++ b/src/state/queries/suggested-follows.ts @@ -105,17 +105,16 @@ export function useSuggestedFollowsQuery(options?: SuggestedFollowsOptions) { export function useSuggestedFollowsByActorQuery({did}: {did: string}) { const agent = useAgent() - return useQuery<AppBskyGraphGetSuggestedFollowsByActor.OutputSchema, Error>({ + return useQuery({ queryKey: suggestedFollowsByActorQueryKey(did), queryFn: async () => { const res = await agent.app.bsky.graph.getSuggestedFollowsByActor({ actor: did, }) - const data = res.data.isFallback ? {suggestions: []} : res.data - data.suggestions = data.suggestions.filter(profile => { - return !profile.viewer?.following - }) - return data + const suggestions = res.data.isFallback + ? [] + : res.data.suggestions.filter(profile => !profile.viewer?.following) + return {suggestions} }, }) } diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index fb9cdb065..28b8f4ceb 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -38,7 +38,8 @@ import {PostMeta} from '#/view/com/util/PostMeta' import {Text} from '#/view/com/util/text/Text' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a} from '#/alf' -import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' +import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' +import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' @@ -52,6 +53,7 @@ interface FeedItemProps { record: AppBskyFeedPost.Record reason: | AppBskyFeedDefs.ReasonRepost + | AppBskyFeedDefs.ReasonPin | ReasonFeedSource | {[k: string]: unknown; $type: string} | undefined @@ -295,7 +297,7 @@ let FeedItemInner = ({ ) } onBeforePress={onOpenReposter}> - <Repost + <RepostIcon style={{color: pal.colors.textLight, marginRight: 3}} width={14} height={14} @@ -337,6 +339,21 @@ let FeedItemInner = ({ )} </Text> </Link> + ) : AppBskyFeedDefs.isReasonPin(reason) ? ( + <View style={styles.includeReason}> + <PinIcon + style={{color: pal.colors.textLight, marginRight: 3}} + width={14} + height={14} + /> + <Text + type="sm-bold" + style={pal.textLight} + lineHeight={1.2} + numberOfLines={1}> + <Trans>Pinned</Trans> + </Text> + </View> ) : null} </View> </View> diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 03b6dd233..fe6efc02f 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -1,4 +1,4 @@ -import React, {memo} from 'react' +import React, {memo, useCallback} from 'react' import { Platform, Pressable, @@ -18,9 +18,13 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' +import {getCurrentRoute} from '#/lib/routes/helpers' import {makeProfileLink} from '#/lib/routes/links' import {CommonNavigatorParams, NavigationProp} from '#/lib/routes/types' +import {shareUrl} from '#/lib/sharing' import {richTextToString} from '#/lib/strings/rich-text-helpers' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {useTheme} from '#/lib/ThemeContext' import {getTranslatorLink} from '#/locale/helpers' import {logger} from '#/logger' import {isWeb} from '#/platform/detection' @@ -29,6 +33,7 @@ import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useLanguagePrefs} from '#/state/preferences' import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' import {useOpenLink} from '#/state/preferences/in-app-browser' +import {usePinnedPostMutation} from '#/state/queries/pinned-post' import { usePostDeleteMutation, useThreadMuteMutationQueue, @@ -38,10 +43,6 @@ import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util' import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' import {useSession} from '#/state/session' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' -import {getCurrentRoute} from 'lib/routes/helpers' -import {shareUrl} from 'lib/sharing' -import {toShareUrl} from 'lib/strings/url-helpers' -import {useTheme} from 'lib/ThemeContext' import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf' import {useDialogControl} from '#/components/Dialog' import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' @@ -65,6 +66,7 @@ import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/E import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane' +import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' @@ -106,7 +108,9 @@ let PostDropdownBtn = ({ const {_} = useLingui() const defaultCtrlColor = theme.palette.default.postCtrl const langPrefs = useLanguagePrefs() - const postDeleteMutation = usePostDeleteMutation() + const {mutateAsync: deletePostMutate} = usePostDeleteMutation() + const {mutateAsync: pinPostMutate, isPending: isPinPending} = + usePinnedPostMutation() const hiddenPosts = useHiddenPosts() const {hidePost} = useHiddenPostsApi() const feedFeedback = useFeedFeedbackContext() @@ -149,8 +153,9 @@ let PostDropdownBtn = ({ threadgateRecord, }) const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri) + const isPinned = post.viewer?.pinned - const {mutateAsync: toggleQuoteDetachment, isPending} = + const {mutateAsync: toggleQuoteDetachment, isPending: isDetachPending} = useToggleQuoteDetachmentMutation() const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ @@ -169,7 +174,7 @@ let PostDropdownBtn = ({ ) const onDeletePost = React.useCallback(() => { - postDeleteMutation.mutateAsync({uri: postUri}).then( + deletePostMutate({uri: postUri}).then( () => { Toast.show(_(msg`Post deleted`)) @@ -197,7 +202,7 @@ let PostDropdownBtn = ({ }, [ navigation, postUri, - postDeleteMutation, + deletePostMutate, postAuthor, currentAccount, isAuthor, @@ -344,6 +349,14 @@ let PostDropdownBtn = ({ toggleReplyVisibility, ]) + const onPressPin = useCallback(() => { + pinPostMutate({ + postUri, + postCid, + action: isPinned ? 'unpin' : 'pin', + }) + }, [isPinned, pinPostMutate, postCid, postUri]) + return ( <EventStopper onKeyDown={false}> <Menu.Root> @@ -372,6 +385,33 @@ let PostDropdownBtn = ({ </Menu.Trigger> <Menu.Outer> + {isAuthor && ( + <> + <Menu.Group> + <Menu.Item + testID="pinPostBtn" + label={ + isPinned + ? _(msg`Unpin from profile`) + : _(msg`Pin to your profile`) + } + disabled={isPinPending} + onPress={onPressPin}> + <Menu.ItemText> + {isPinned + ? _(msg`Unpin from profile`) + : _(msg`Pin to your profile`)} + </Menu.ItemText> + <Menu.ItemIcon + icon={isPinPending ? Loader : PinIcon} + position="right" + /> + </Menu.Item> + </Menu.Group> + <Menu.Divider /> + </> + )} + <Menu.Group> {(!hideInPWI || hasSession) && ( <> @@ -536,7 +576,7 @@ let PostDropdownBtn = ({ {canDetachQuote && ( <Menu.Item - disabled={isPending} + disabled={isDetachPending} testID="postDropdownHideBtn" label={ quoteEmbed.isDetached @@ -555,7 +595,7 @@ let PostDropdownBtn = ({ </Menu.ItemText> <Menu.ItemIcon icon={ - isPending + isDetachPending ? Loader : quoteEmbed.isDetached ? Eye diff --git a/yarn.lock b/yarn.lock index 6378f7f34..8f68ea4f3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -85,15 +85,15 @@ multiformats "^9.9.0" tlds "^1.234.0" -"@atproto/api@^0.13.7": - version "0.13.7" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.7.tgz#072eba2025d5251505f17b0b5d2de33749ea5ee4" - integrity sha512-41kSLmFWDbuPOenb52WRq1lnBkSZrL+X29tWcvEt6SZXK4xBoKAalw1MjF+oabhzff12iMtNaNvmmt2fu1L+cw== +"@atproto/api@^0.13.8": + version "0.13.8" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.8.tgz#44aa4992442812604bccf9eebe4d1db9ed64c179" + integrity sha512-1RlvMg8iAT5k3F0U3549ct9+jXthlXtfFXIfTXLyXXFe9Exfvmr7ZJ1ra41vU1nXGsoouCoTxj7kdzC4MY8JZg== dependencies: - "@atproto/common-web" "^0.3.0" - "@atproto/lexicon" "^0.4.1" + "@atproto/common-web" "^0.3.1" + "@atproto/lexicon" "^0.4.2" "@atproto/syntax" "^0.3.0" - "@atproto/xrpc" "^0.6.2" + "@atproto/xrpc" "^0.6.3" await-lock "^2.2.2" multiformats "^9.9.0" tlds "^1.234.0" @@ -179,6 +179,16 @@ uint8arrays "3.0.0" zod "^3.21.4" +"@atproto/common-web@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@atproto/common-web/-/common-web-0.3.1.tgz#86f8efb10a4b9073839cee914c6c08a664917cc4" + integrity sha512-N7wiTnus5vAr+lT//0y8m/FaHHLJ9LpGuEwkwDAeV3LCiPif4m/FS8x/QOYrx1PdZQwKso95RAPzCGWQBH5j6Q== + dependencies: + graphemer "^1.4.0" + multiformats "^9.9.0" + uint8arrays "3.0.0" + zod "^3.23.8" + "@atproto/common@0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.1.0.tgz#4216a8fef5b985ab62ac21252a0f8ca0f4a0f210" @@ -292,6 +302,17 @@ multiformats "^9.9.0" zod "^3.23.8" +"@atproto/lexicon@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.2.tgz#fcc92cdb82ae248b034b172763d6dbadfb00a829" + integrity sha512-CXoOkhcdF3XVUnR2oNgCs2ljWfo/8zUjxL5RIhJW/UNLp/FSl+KpF8Jm5fbk8Y/XXVPGRAsv9OYfxyU/14N/pw== + dependencies: + "@atproto/common-web" "^0.3.1" + "@atproto/syntax" "^0.3.0" + iso-datestring-validator "^2.2.2" + multiformats "^9.9.0" + zod "^3.23.8" + "@atproto/oauth-provider@^0.1.2": version "0.1.2" resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.1.2.tgz#a576a4c7795c7938a994e76192c19a2e73ffcddf" @@ -444,12 +465,12 @@ "@atproto/lexicon" "^0.4.1" zod "^3.23.8" -"@atproto/xrpc@^0.6.2": - version "0.6.2" - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.2.tgz#634228a7e533de01bda2214837d11574fdadad55" - integrity sha512-as/gb08xJb02HAGNrSQSumCe10WnOAcnM6bR6KMatQyQJuEu7OY6ZDSTM/4HfjjoxsNqdvPmbYuoUab1bKTNlA== +"@atproto/xrpc@^0.6.3": + version "0.6.3" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.3.tgz#5942fc24644ad182b913af526efaa06a43d89478" + integrity sha512-S3tRvOdA9amPkKLll3rc4vphlDitLrkN5TwWh5Tu/jzk7mnobVVE3akYgICV9XCNHKjWM+IAPxFFI2qi+VW6nQ== dependencies: - "@atproto/lexicon" "^0.4.1" + "@atproto/lexicon" "^0.4.2" zod "^3.23.8" "@aws-crypto/crc32@3.0.0": |