diff options
Diffstat (limited to 'src')
-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 |
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 |