about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib/api/feed/author.ts13
-rw-r--r--src/state/cache/post-shadow.ts2
-rw-r--r--src/state/queries/pinned-post.ts87
-rw-r--r--src/state/queries/post-feed.ts1
-rw-r--r--src/state/queries/profile.ts3
-rw-r--r--src/state/queries/suggested-follows.ts11
-rw-r--r--src/view/com/posts/FeedItem.tsx21
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx62
8 files changed, 178 insertions, 22 deletions
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