From b87d29a13dfe94e6f4963e548589fb2c2ff4782f Mon Sep 17 00:00:00 2001 From: xan.lol Date: Wed, 29 Apr 2026 08:50:13 +0000 Subject: [PATCH] feat: hold interaction buttons to use different account 👥 made it work for replies (composer already worked!), likes, reposts, saves, and follows. - maybe add more down the line? - Undo on the toasts? - show which accounts have already done an action? --- src/components/Button.tsx | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- src/components/EphemeralAccountSwitcher.tsx | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- src/components/PostControls/BookmarkButton.tsx | 3 +++ src/components/PostControls/RepostButton.tsx | 18 +++++++++--------- src/components/PostControls/RepostButton.web.tsx | 3 +++ src/components/PostControls/index.tsx | 466 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------------------------------------------------------- src/components/ProfileCard.tsx | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------- src/components/hooks/useEphemeralFollowAction.ts | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/components/hooks/useRunWithEphemeralAgent.ts | 19 +++++++++++++++++++ src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx | 33 +++++++++++++++++++++++++++++++-- src/screens/Profile/Header/ProfileHeaderStandard.tsx | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------- src/screens/VideoFeed/index.tsx | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------- src/state/shell/composer/index.tsx | 1 + src/view/com/composer/Composer.tsx | 9 ++++++++- src/view/shell/Composer.ios.tsx | 1 + src/view/shell/Composer.tsx | 1 + src/view/shell/Composer.web.tsx | 1 + 17 file(s) changed, 868 insertion(s)(+), 219 deletion(s)(-) diff --git a/src/components/Button.tsx b/src/components/Button.tsx --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -4,6 +4,7 @@ forwardRef, useCallback, useContext, useMemo, + useRef, useState, } from 'react' import { @@ -11,6 +12,7 @@ type AccessibilityProps, type GestureResponderEvent, type MouseEvent, type NativeSyntheticEvent, + type PointerEvent, Pressable, type PressableProps, type StyleProp, @@ -26,6 +28,7 @@ import {useThemePrefs} from '#/state/shell' import {atoms as a, flatten, select, useTheme} from '#/alf' import {type Props as SVGIconProps} from '#/components/icons/common' import {Text} from '#/components/Typography' +import {IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' /** * The `Button` component, and some extensions of it like `Link` are intended @@ -142,6 +145,8 @@ disabled = false, style, hoverStyle: hoverStyleProp, PressableComponent = Pressable, + onPress: onPressOuter, + onLongPress: onLongPressOuter, onPressIn: onPressInOuter, onPressOut: onPressOutOuter, onHoverIn: onHoverInOuter, @@ -164,11 +169,20 @@ const enableSquareButtons = useEnableSquareButtons() const t = useTheme() + const longPressTimerRef = useRef | null>(null) + const longPressTriggeredRef = useRef(false) const [state, setState] = useState({ pressed: false, hovered: false, focused: false, }) + + const clearLongPressTimer = useCallback(() => { + if (longPressTimerRef.current) { + clearTimeout(longPressTimerRef.current) + longPressTimerRef.current = null + } + }, []) const onPressIn = useCallback( (e: GestureResponderEvent) => { @@ -176,6 +190,7 @@ setState(s => ({ ...s, pressed: true, })) + longPressTriggeredRef.current = false onPressInOuter?.(e) }, [setState, onPressInOuter], @@ -186,10 +201,40 @@ setState(s => ({ ...s, pressed: false, })) + clearLongPressTimer() onPressOutOuter?.(e) }, - [setState, onPressOutOuter], + [clearLongPressTimer, setState, onPressOutOuter], ) + const onPress = useCallback( + (e: GestureResponderEvent) => { + if (longPressTriggeredRef.current) { + longPressTriggeredRef.current = false + return + } + onPressOuter?.(e) + }, + [onPressOuter], + ) + const onPointerDown = useCallback( + (e: PointerEvent) => { + if (onLongPressOuter && IS_WEB && !IS_WEB_TOUCH_DEVICE) { + clearLongPressTimer() + longPressTriggeredRef.current = false + longPressTimerRef.current = setTimeout(() => { + longPressTriggeredRef.current = true + onLongPressOuter(e as unknown as GestureResponderEvent) + }, 500) + } + }, + [clearLongPressTimer, onLongPressOuter], + ) + const onPointerUp = useCallback(() => { + clearLongPressTimer() + }, [clearLongPressTimer]) + const onPointerLeave = useCallback(() => { + clearLongPressTimer() + }, [clearLongPressTimer]) const onHoverIn = useCallback( (e: MouseEvent) => { setState(s => ({ @@ -554,6 +599,14 @@ e.preventDefault(), + } + : {}) as any)} // @ts-ignore - this will always be a pressable ref={ref} aria-label={label} @@ -576,6 +629,8 @@ : []), ]} onPressIn={onPressIn} onPressOut={onPressOut} + onPress={onPress} + onLongPress={!IS_WEB || IS_WEB_TOUCH_DEVICE ? onLongPressOuter : undefined} onHoverIn={onHoverIn} onHoverOut={onHoverOut} onFocus={onFocus} diff --git a/src/components/EphemeralAccountSwitcher.tsx b/src/components/EphemeralAccountSwitcher.tsx --- a/src/components/EphemeralAccountSwitcher.tsx +++ b/src/components/EphemeralAccountSwitcher.tsx @@ -1,4 +1,5 @@ import {useMemo} from 'react' +import {View} from 'react-native' import {type AppBskyActorDefs} from '@atproto/api' import {useLingui} from '@lingui/react/macro' @@ -8,7 +9,6 @@ import {SwitchMenuItems} from '#/view/shell/desktop/LeftNav' import {useDialogControl} from '#/components/Dialog' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' import * as Menu from '#/components/Menu' -import {type TriggerChildProps} from '#/components/Menu/types' import * as Prompt from '#/components/Prompt' import {IS_WEB_TOUCH_DEVICE} from '#/env' @@ -17,18 +17,32 @@ account: SessionAccount profile?: AppBskyActorDefs.ProfileViewDetailed } +type SwitcherTriggerProps = { + ref: null + onPress: (() => void) | undefined + onLongPress?: (() => void) | undefined + onFocus: () => void + onBlur: () => void + onPressIn: () => void + onPressOut: () => void + accessibilityLabel: string + accessibilityRole: 'button' +} + export function EphemeralAccountSwitcher({ selectedDid, title, onSelectAccount, + triggerBehavior = 'press', renderTrigger, }: { selectedDid: string title: string onSelectAccount: (account: SessionAccount) => void + triggerBehavior?: 'press' | 'longPress' renderTrigger: (args: { currentProfile?: AppBskyActorDefs.ProfileViewDetailed - triggerProps: TriggerChildProps['props'] + triggerProps: SwitcherTriggerProps }) => React.ReactNode }) { const {t: l} = useLingui() @@ -38,6 +52,7 @@ const {data} = useProfilesQuery({ handles: accounts.map(acc => acc.did), }) const control = useDialogControl() + const menuControl = Menu.useMenuControl() const signOutPromptControl = Prompt.usePromptControl() const profiles = data?.profiles @@ -51,15 +66,73 @@ profile: profiles?.find(p => p.did === account.did), })), [accounts, profiles, selectedDid], ) + const hasSwitcherAccounts = switcherAccounts.length > 0 + + if (!hasSwitcherAccounts) { + return renderTrigger({ + currentProfile, + triggerProps: { + ref: null, + onPress: undefined, + onLongPress: undefined, + onFocus: () => {}, + onBlur: () => {}, + onPressIn: () => {}, + onPressOut: () => {}, + accessibilityLabel: l`Switch accounts`, + accessibilityRole: 'button', + }, + }) + } + + if (!IS_WEB_TOUCH_DEVICE && triggerBehavior === 'longPress') { + return ( + + + {({props}) => ( + + {renderTrigger({ + currentProfile, + triggerProps: { + ref: null, + onPress: undefined, + onLongPress: () => menuControl.open(), + onFocus: () => {}, + onBlur: () => {}, + onPressIn: () => {}, + onPressOut: () => {}, + accessibilityLabel: l`Switch accounts`, + accessibilityRole: 'button', + }, + })} + + )} + + + + ) + } if (IS_WEB_TOUCH_DEVICE) { + const openProps = + triggerBehavior === 'longPress' + ? {onPress: undefined, onLongPress: control.open} + : {onPress: control.open, onLongPress: undefined} + return ( <> {renderTrigger({ currentProfile, triggerProps: { ref: null, - onPress: control.open, + ...openProps, onFocus: () => {}, onBlur: () => {}, onPressIn: () => {}, @@ -87,7 +160,7 @@ {({props}) => renderTrigger({ currentProfile, - triggerProps: props, + triggerProps: props as SwitcherTriggerProps, }) } diff --git a/src/components/PostControls/BookmarkButton.tsx b/src/components/PostControls/BookmarkButton.tsx --- a/src/components/PostControls/BookmarkButton.tsx +++ b/src/components/PostControls/BookmarkButton.tsx @@ -22,11 +22,13 @@ post, big, logContext, hitSlop, + onLongPress, }: { post: Shadow big?: boolean logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' hitSlop?: Insets + onLongPress?: () => void }): React.ReactNode { const t = useTheme() const ax = useAnalytics() @@ -144,6 +146,7 @@ ? _(msg`Remove from saved posts`) : _(msg`Add to saved posts`) } onPress={onHandlePress} + onLongPress={onLongPress} hitSlop={hitSlop}> diff --git a/src/components/PostControls/RepostButton.tsx b/src/components/PostControls/RepostButton.tsx --- a/src/components/PostControls/RepostButton.tsx +++ b/src/components/PostControls/RepostButton.tsx @@ -24,6 +24,7 @@ isReposted: boolean repostCount?: number onRepost: () => void onQuote: () => void + onLongPress?: () => void big?: boolean embeddingDisabled: boolean } @@ -33,6 +34,7 @@ isReposted, repostCount, onRepost, onQuote, + onLongPress, big, embeddingDisabled, }: Props): React.ReactNode => { @@ -44,7 +46,7 @@ const formatPostStatCount = useFormatPostStatCount() const onPress = () => requireAuth(() => dialogControl.open()) - const onLongPress = () => + const onDefaultLongPress = () => requireAuth(() => { if (embeddingDisabled) { dialogControl.open() @@ -54,14 +56,14 @@ } }) return ( - <> + - + ) } RepostButton = memo(RepostButton) export {RepostButton} -let RepostButtonDialogInner = ({ +export const RepostButtonDialogInner = memo(function RepostButtonDialogInner({ isReposted, onRepost, onQuote, @@ -119,7 +121,7 @@ isReposted: boolean onRepost: () => void onQuote: () => void embeddingDisabled: boolean -}): React.ReactNode => { +}): React.ReactNode { const t = useTheme() const {_} = useLingui() const playHaptic = useHaptics() @@ -213,6 +215,4 @@ ) -} -RepostButtonDialogInner = memo(RepostButtonDialogInner) -export {RepostButtonDialogInner} +}) diff --git a/src/components/PostControls/RepostButton.web.tsx b/src/components/PostControls/RepostButton.web.tsx --- a/src/components/PostControls/RepostButton.web.tsx +++ b/src/components/PostControls/RepostButton.web.tsx @@ -19,6 +19,7 @@ isReposted: boolean repostCount?: number onRepost: () => void onQuote: () => void + onLongPress?: () => void big?: boolean embeddingDisabled: boolean } @@ -28,6 +29,7 @@ isReposted, repostCount, onRepost, onQuote, + onLongPress, big, embeddingDisabled, }: Props) => { @@ -49,6 +51,7 @@ active={isReposted} activeColor={t.palette.positive_500} label={props.accessibilityLabel} big={big} + onLongPress={onLongPress} {...props}> {typeof repostCount !== 'undefined' && repostCount > 0 && ( diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx --- a/src/components/PostControls/index.tsx +++ b/src/components/PostControls/index.tsx @@ -6,7 +6,7 @@ type AppBskyFeedPost, type AppBskyFeedThreadgate, type RichText as RichTextAPI, } from '@atproto/api' -import {plural} from '@lingui/core/macro' +import {msg, plural} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro' import {CountWheel} from '#/lib/custom-animations/CountWheel' @@ -24,7 +24,7 @@ useGetPost, usePostLikeMutationQueue, usePostRepostMutationQueue, } from '#/state/queries/post' -import {useRequireAuth} from '#/state/session' +import {useRequireAuth, useSession} from '#/state/session' import { ProgressGuideAction, useProgressGuideControls, @@ -35,7 +35,9 @@ import {Reply as Bubble} from '#/components/icons/Reply' import {useFormatPostStatCount} from '#/components/PostControls/util' import * as Skele from '#/components/Skeleton' import * as Toast from '#/components/Toast' +import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher' import {useAnalytics} from '#/analytics' +import {useRunWithEphemeralAgent} from '../hooks/useRunWithEphemeralAgent' import {useAutoLikeOnRepost} from '../../state/preferences/auto-like-on-repost.tsx' import {BookmarkButton} from './BookmarkButton' import { @@ -85,7 +87,9 @@ const t = useTheme() const {t: l} = useLingui() const {openComposer} = useOpenComposer() const {feedDescriptor} = useFeedFeedbackContext() + const {accounts, currentAccount} = useSession() const getPost = useGetPost() + const runWithEphemeralAgent = useRunWithEphemeralAgent() const [queueLike, queueUnlike] = usePostLikeMutationQueue( post, viaRepost, @@ -242,23 +246,186 @@ reqId, }) } + const onReplyAsAccount = (accountDid: string) => { + setTimeout(() => { + ax.metric('post:clickReply', { + uri: post.uri, + authorDid: post.author.did, + logContext, + feedDescriptor, + }) + openComposer({ + activeAccountDid: accountDid, + replyTo: { + uri: post.uri, + cid: post.cid, + text: record.text || '', + author: post.author, + embed: post.embed, + langs: record.langs, + }, + onPost: onPostReply, + logContext: 'PostReply', + }) + }, 0) + } + const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({ variant, big, gtPhone, }) + const hasAlternateAccounts = accounts.some( + account => account.did !== currentAccount?.did, + ) + + const onSelectLikeAccount = async (account: (typeof accounts)[number]) => { + try { + const wasLiked = await runWithEphemeralAgent(account, async agent => { + const res = await agent.getPosts({uris: [post.uri]}) + const target = res.data.posts[0] + const likeUri = target?.viewer?.like + + if (likeUri) { + await agent.deleteLike(likeUri) + return true + } + + await agent.like(post.uri, post.cid) + return false + }) + + Toast.show( + wasLiked + ? l`Removed like as @${account.handle}` + : l`Liked as @${account.handle}`, + ) + } catch (e) { + Toast.show(l`An issue occurred, please try again.`, { + type: 'error', + }) + } + } + + const onSelectRepostAccount = async (account: (typeof accounts)[number]) => { + try { + const wasReposted = await runWithEphemeralAgent(account, async agent => { + const res = await agent.getPosts({uris: [post.uri]}) + const target = res.data.posts[0] + const repostUri = target?.viewer?.repost + + if (repostUri) { + await agent.deleteRepost(repostUri) + return true + } + + await agent.repost(post.uri, post.cid) + return false + }) + + Toast.show( + wasReposted + ? l`Removed repost as @${account.handle}` + : l`Reposted as @${account.handle}`, + ) + } catch (e) { + Toast.show(l`An issue occurred, please try again.`, { + type: 'error', + }) + } + } + + const onSelectBookmarkAccount = async ( + account: (typeof accounts)[number], + ) => { + try { + const wasBookmarked = await runWithEphemeralAgent(account, async agent => { + const res = await agent.getPosts({uris: [post.uri]}) + const target = res.data.posts[0] + + if (target?.viewer?.bookmarked) { + await agent.app.bsky.bookmark.deleteBookmark({uri: post.uri}) + return true + } + + await agent.app.bsky.bookmark.createBookmark({ + uri: post.uri, + cid: post.cid, + }) + return false + }) + + Toast.show( + wasBookmarked + ? l`Removed save as @${account.handle}` + : l`Saved as @${account.handle}`, + ) + } catch (e) { + Toast.show(l`An issue occurred, please try again.`, { + type: 'error', + }) + } + } + + const renderLikeButton = (onLongPress?: () => void) => ( + requireAuth(() => onPressToggleLike())} + onLongPress={onLongPress} + label={ + post.viewer?.like + ? l({ + message: `Unlike (${plural(post.likeCount || 0, { + one: '# like', + other: '# likes', + })})`, + comment: + 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', + }) + : l({ + message: `Like (${plural(post.likeCount || 0, { + one: '# like', + other: '# likes', + })})`, + comment: + 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', + }) + }> + + {!disableLikesMetrics ? ( + ( + + {formatPostStatCount(count)} + + )} + /> + ) : null} + + ) return ( - - + <> + + - + {currentAccount && hasAlternateAccounts && !replyDisabled ? ( + { + onReplyAsAccount(account.did) + }} + renderTrigger={({triggerProps}) => ( + requireAuth(() => { ax.metric('post:clickReply', { uri: post.uri, @@ -280,97 +454,154 @@ feedDescriptor, }) onPressReply() }) - : undefined - } - label={l({ - message: `Reply (${plural(post.replyCount || 0, { - one: '# reply', - other: '# replies', - })})`, - comment: - 'Accessibility label for the reply button, verb form followed by number of replies and noun form', - })} - big={big}> - - {typeof post.replyCount !== 'undefined' && - post.replyCount > 0 && - !disableReplyMetrics && ( - - {formatPostStatCount(post.replyCount)} - - )} - - - - void onRepost()} - onQuote={onQuote} - big={big} - embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} - /> - - - requireAuth(() => onPressToggleLike())} - label={ - post.viewer?.like - ? l({ - message: `Unlike (${plural(post.likeCount || 0, { - one: '# like', - other: '# likes', + } + onLongPress={triggerProps.onLongPress} + label={l({ + message: `Reply (${plural(post.replyCount || 0, { + one: '# reply', + other: '# replies', })})`, comment: - 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', - }) - : l({ - message: `Like (${plural(post.likeCount || 0, { - one: '# like', - other: '# likes', - })})`, - comment: - 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', - }) - }> - + + {typeof post.replyCount !== 'undefined' && + post.replyCount > 0 && + !disableReplyMetrics && ( + + {formatPostStatCount(post.replyCount)} + + )} + + )} /> - {!disableLikesMetrics ? ( - ( - - {formatPostStatCount(count)} + ) : ( + + requireAuth(() => { + ax.metric('post:clickReply', { + uri: post.uri, + authorDid: post.author.did, + logContext, + feedDescriptor, + }) + onPressReply() + }) + : undefined + } + label={l({ + message: `Reply (${plural(post.replyCount || 0, { + one: '# reply', + other: '# replies', + })})`, + comment: + 'Accessibility label for the reply button, verb form followed by number of replies and noun form', + })} + big={big}> + + {typeof post.replyCount !== 'undefined' && + post.replyCount > 0 && + !disableReplyMetrics && ( + + {formatPostStatCount(post.replyCount)} )} + + )} + + + {currentAccount && hasAlternateAccounts ? ( + { + void onSelectRepostAccount(account) + }} + renderTrigger={({triggerProps}) => ( + void onRepost()} + onQuote={onQuote} + onLongPress={triggerProps.onLongPress} + big={big} + embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} + /> + )} /> - ) : null} - + ) : ( + void onRepost()} + onQuote={onQuote} + big={big} + embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} + /> + )} + + + {currentAccount && hasAlternateAccounts ? ( + { + void onSelectLikeAccount(account) + }} + renderTrigger={({triggerProps}) => + renderLikeButton(triggerProps.onLongPress) + } + /> + ) : ( + renderLikeButton() + )} {/* Spacer! */} - - - + + + {currentAccount && hasAlternateAccounts ? ( + { + void onSelectBookmarkAccount(account) + }} + renderTrigger={({triggerProps}) => ( + + )} + /> + ) : ( + + )} - + + - + ) } PostControls = memo(PostControls) diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -38,6 +38,7 @@ ButtonIcon, type ButtonProps, ButtonText, } from '#/components/Button' +import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' import {Link as InternalLink, type LinkProps} from '#/components/Link' @@ -49,6 +50,7 @@ import {Text} from '#/components/Typography' import {type Metrics} from '#/analytics' import {useActorStatus} from '#/features/liveNow' import type * as bsky from '#/types/bsky' +import {useEphemeralFollowAction} from './hooks/useEphemeralFollowAction' export function Default({ profile, @@ -479,6 +481,7 @@ position, contextProfileDid, ...rest }: FollowButtonProps) { + const {currentAccount, accounts} = useSession() const {t: l} = useLingui() const profile = useProfileShadow(profileUnshadowed) const moderation = moderateProfile(profile, moderationOpts) @@ -489,6 +492,14 @@ position, contextProfileDid, ) const isRound = Boolean(rest.shape && rest.shape === 'round') + const onSelectEphemeralAccount = useEphemeralFollowAction({ + profile, + logContext, + onFollow, + }) + const hasAlternateAccounts = accounts.some( + account => account.did !== currentAccount?.did, + ) const onPressFollow = async (e: GestureResponderEvent) => { e.preventDefault() @@ -561,39 +572,59 @@ profile.viewer.blocking || profile.viewer.blockingByList ) return null + const viewer = profile.viewer + + const renderFollowButton = (onLongPress?: () => void) => + viewer.following ? ( + + ) : ( + + ) return ( - {profile.viewer.following ? ( - + {currentAccount && hasAlternateAccounts ? ( + { + void onSelectEphemeralAccount(account) + }} + renderTrigger={({triggerProps}) => + renderFollowButton(triggerProps.onLongPress) + } + /> ) : ( - + renderFollowButton() )} ) diff --git a/src/components/hooks/useEphemeralFollowAction.ts b/src/components/hooks/useEphemeralFollowAction.ts new file mode 100644 --- /dev/null +++ b/src/components/hooks/useEphemeralFollowAction.ts @@ -0,0 +1,80 @@ +import {useCallback} from 'react' +import {type AppBskyActorDefs} from '@atproto/api' +import {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {logger} from '#/logger' +import {type Shadow} from '#/state/cache/types' +import {type SessionAccount} from '#/state/session' +import * as Toast from '#/components/Toast' +import {type Metrics} from '#/analytics/metrics' +import type * as bsky from '#/types/bsky' +import {useRunWithEphemeralAgent} from './useRunWithEphemeralAgent' + +export function useEphemeralFollowAction({ + profile, + logContext: _logContext, + onFollow, + onUnfollow, +}: { + profile: Shadow + logContext: Metrics['profile:follow']['logContext'] & + Metrics['profile:unfollow']['logContext'] + onFollow?: () => void + onUnfollow?: () => void +}) { + const {_} = useLingui() + const runWithEphemeralAgent = useRunWithEphemeralAgent() + + return useCallback( + async (account: SessionAccount) => { + try { + const result = await runWithEphemeralAgent(account, async agent => { + const res = await agent.getProfile({actor: profile.did}) + const target = + res.data as AppBskyActorDefs.ProfileViewDetailed + const followingUri = target.viewer?.following + + if (followingUri) { + await agent.deleteFollow(followingUri) + return {followed: false} + } + + await agent.follow(profile.did) + return {followed: true} + }) + + if (result.followed) { + onFollow?.() + Toast.show( + _( + msg`Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )} as @${account.handle}`, + ), + ) + } else { + onUnfollow?.() + Toast.show( + _( + msg`No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + )} as @${account.handle}`, + ), + ) + } + } catch (e) { + logger.error('useEphemeralFollowAction: failed to toggle follow', { + message: String(e), + targetDid: profile.did, + accountDid: account.did, + }) + Toast.show(_(msg`An issue occurred, please try again.`), { + type: 'error', + }) + } + }, + [_, onFollow, onUnfollow, profile, runWithEphemeralAgent], + ) +} diff --git a/src/components/hooks/useRunWithEphemeralAgent.ts b/src/components/hooks/useRunWithEphemeralAgent.ts new file mode 100644 --- /dev/null +++ b/src/components/hooks/useRunWithEphemeralAgent.ts @@ -0,0 +1,19 @@ +import {useCallback} from 'react' +import {type BskyAgent} from '@atproto/api' + +import {type SessionAccount, useSessionApi} from '#/state/session' + +export function useRunWithEphemeralAgent() { + const {createEphemeralAgent} = useSessionApi() + + return useCallback( + async ( + account: SessionAccount, + fn: (agent: BskyAgent) => Promise, + ): Promise => { + const agent = await createEphemeralAgent(account) + return await fn(agent) + }, + [createEphemeralAgent], + ) +} diff --git a/src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx b/src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx --- a/src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx @@ -12,9 +12,11 @@ import { useProfileFollowMutationQueue, useProfileQuery, } from '#/state/queries/profile' -import {useRequireAuth} from '#/state/session' +import {useRequireAuth, useSession} from '#/state/session' import {atoms as a, useBreakpoints} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher' +import {useEphemeralFollowAction} from '#/components/hooks/useEphemeralFollowAction' import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import * as Toast from '#/components/Toast' @@ -64,17 +66,25 @@ const navigation = useNavigation() const {_} = useLingui() const {gtMobile} = useBreakpoints() const profile = useProfileShadow(profileUnshadowed) + const {accounts, currentAccount} = useSession() const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, 'PostThreadItem', ) const requireAuth = useRequireAuth() + const onSelectEphemeralAccount = useEphemeralFollowAction({ + profile, + logContext: 'PostThreadItem', + }) const isFollowing = !!profile.viewer?.following const isFollowedBy = !!profile.viewer?.followedBy const [wasFollowing, setWasFollowing] = useState(isFollowing) const enableSquareButtons = useEnableSquareButtons() + const hasAlternateAccounts = accounts.some( + account => account.did !== currentAccount?.did, + ) // This prevents the button from disappearing as soon as we follow. const showFollowBtn = useMemo( @@ -141,10 +151,11 @@ }, [isFollowing, requireAuth, queueFollow, _, queueUnfollow]) if (!showFollowBtn) return null - return ( + const renderFollowButton = (onLongPress?: () => void) => ( + ) + + return ( + currentAccount && hasAlternateAccounts ? ( + { + void onSelectEphemeralAccount(account) + }} + renderTrigger={({triggerProps}) => + renderFollowButton(triggerProps.onLongPress) + } + /> + ) : ( + renderFollowButton() + ) ) } diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -42,6 +42,7 @@ import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {useDialogControl} from '#/components/Dialog' +import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher' import {MessageProfileButton} from '#/components/dms/MessageProfileButton' import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' @@ -64,6 +65,7 @@ import {ProfileHeaderHandle} from './Handle' import {ProfileHeaderMetrics} from './Metrics' import {ProfileHeaderShell} from './Shell' import {ProfileHeaderSuggestedFollows} from './SuggestedFollows' +import {useEphemeralFollowAction} from '#/components/hooks/useEphemeralFollowAction' interface Props { profile: AppBskyActorDefs.ProfileViewDetailed @@ -318,7 +320,7 @@ onUnfollow?: () => void minimal?: boolean }) { const {_} = useLingui() - const {hasSession, currentAccount} = useSession() + const {accounts, hasSession, currentAccount} = useSession() const playHaptic = useHaptics() const requireAuth = useRequireAuth() const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( @@ -329,6 +331,15 @@ const [, queueUnblock] = useProfileBlockMutationQueue(profile) const editProfileControl = useDialogControl() const unblockPromptControl = Prompt.usePromptControl() const hideScaryFollowButtons = useHideScaryFollowButtons() + const onSelectEphemeralAccount = useEphemeralFollowAction({ + profile, + logContext: 'ProfileHeader', + onFollow, + onUnfollow, + }) + const hasAlternateAccounts = accounts.some( + account => account.did !== currentAccount?.did, + ) const isMe = currentAccount?.did === profile.did @@ -462,33 +473,80 @@ )} {(!minimal || !profile.viewer?.following) && !(minimal && hideScaryFollowButtons) && ( - + )} + /> + ) : ( + + Follow + )} + + + )) )} ) : null} diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx --- a/src/screens/VideoFeed/index.tsx +++ b/src/screens/VideoFeed/index.tsx @@ -85,6 +85,7 @@ import {atoms as a, ios, platform, ThemeProvider, useTheme} from '#/alf' import {setSystemUITheme} from '#/alf/util/systemUI' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Divider} from '#/components/Divider' +import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher' import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash' @@ -100,6 +101,7 @@ import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_ANDROID} from '#/env' import * as bsky from '#/types/bsky' +import {useEphemeralFollowAction} from '#/components/hooks/useEphemeralFollowAction' import {Scrubber, VIDEO_PLAYER_BOTTOM_INSET} from './components/Scrubber' function createThreeVideoPlayers( @@ -723,7 +725,7 @@ }) { const {t: l} = useLingui() const t = useTheme() const {openComposer} = useOpenComposer() - const {currentAccount} = useSession() + const {accounts, currentAccount} = useSession() const navigation = useNavigation() const seekingAnimationSV = useSharedValue(0) @@ -731,6 +733,14 @@ const profile = useProfileShadow(post.author) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, 'ImmersiveVideo', + ) + const onSelectEphemeralAccount = useEphemeralFollowAction({ + profile, + logContext: 'ImmersiveVideo', + }) + const hasAlternateAccounts = useMemo( + () => accounts.some(account => account.did !== currentAccount?.did), + [accounts, currentAccount?.did], ) const rkey = new AtUri(post.uri).rkey @@ -839,35 +849,80 @@ {/* show button based on non-reactive version, so it doesn't hide on press */} {post.author.did !== currentAccount?.did && !post.author.viewer?.following && ( - + )} + /> + ) : ( + + + {profile.viewer?.following ? ( + Following + ) : ( + Follow + )} + + + ) )} {record?.text?.trim() && ( diff --git a/src/state/shell/composer/index.tsx b/src/state/shell/composer/index.tsx --- a/src/state/shell/composer/index.tsx +++ b/src/state/shell/composer/index.tsx @@ -49,6 +49,7 @@ | 'Deeplink' | 'Other' export interface ComposerOpts { + activeAccountDid?: string replyTo?: ComposerOptsPostRef onPost?: (postUri: string | undefined) => void onPostSuccess?: (data: OnPostSuccessData) => void diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -196,6 +196,7 @@ } type Props = ComposerOpts export const ComposePost = ({ + activeAccountDid: initialActiveAccountDid, replyTo, onPost, onPostSuccess, @@ -218,7 +219,13 @@ const sessionApi = useSessionApi() const queryClient = useQueryClient() const currentDid = currentAccount!.did - const [activeAccountDid, setActiveAccountDid] = useState(currentDid) + const [activeAccountDid, setActiveAccountDid] = useState( + initialActiveAccountDid ?? currentDid, + ) + + useEffect(() => { + setActiveAccountDid(initialActiveAccountDid ?? currentDid) + }, [initialActiveAccountDid, currentDid]) const {closeComposer} = useComposerControls() const {requestSwitchToAccount} = useLoggedOutViewControls() diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx --- a/src/view/shell/Composer.ios.tsx +++ b/src/view/shell/Composer.ios.tsx @@ -38,6 +38,7 @@