Bluesky app fork with some witchin' additions ๐Ÿ’ซ witchsky.app
bluesky fork client
117
fork

Configure Feed

Select the types of activity you want to include in your feed.

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?

+868 -219
+56 -1
src/components/Button.tsx
··· 4 4 useCallback, 5 5 useContext, 6 6 useMemo, 7 + useRef, 7 8 useState, 8 9 } from 'react' 9 10 import { ··· 11 12 type GestureResponderEvent, 12 13 type MouseEvent, 13 14 type NativeSyntheticEvent, 15 + type PointerEvent, 14 16 Pressable, 15 17 type PressableProps, 16 18 type StyleProp, ··· 26 28 import {atoms as a, flatten, select, useTheme} from '#/alf' 27 29 import {type Props as SVGIconProps} from '#/components/icons/common' 28 30 import {Text} from '#/components/Typography' 31 + import {IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' 29 32 30 33 /** 31 34 * The `Button` component, and some extensions of it like `Link` are intended ··· 142 145 style, 143 146 hoverStyle: hoverStyleProp, 144 147 PressableComponent = Pressable, 148 + onPress: onPressOuter, 149 + onLongPress: onLongPressOuter, 145 150 onPressIn: onPressInOuter, 146 151 onPressOut: onPressOutOuter, 147 152 onHoverIn: onHoverInOuter, ··· 164 169 const enableSquareButtons = useEnableSquareButtons() 165 170 166 171 const t = useTheme() 172 + const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) 173 + const longPressTriggeredRef = useRef(false) 167 174 const [state, setState] = useState({ 168 175 pressed: false, 169 176 hovered: false, 170 177 focused: false, 171 178 }) 179 + 180 + const clearLongPressTimer = useCallback(() => { 181 + if (longPressTimerRef.current) { 182 + clearTimeout(longPressTimerRef.current) 183 + longPressTimerRef.current = null 184 + } 185 + }, []) 172 186 173 187 const onPressIn = useCallback( 174 188 (e: GestureResponderEvent) => { ··· 176 190 ...s, 177 191 pressed: true, 178 192 })) 193 + longPressTriggeredRef.current = false 179 194 onPressInOuter?.(e) 180 195 }, 181 196 [setState, onPressInOuter], ··· 186 201 ...s, 187 202 pressed: false, 188 203 })) 204 + clearLongPressTimer() 189 205 onPressOutOuter?.(e) 190 206 }, 191 - [setState, onPressOutOuter], 207 + [clearLongPressTimer, setState, onPressOutOuter], 192 208 ) 209 + const onPress = useCallback( 210 + (e: GestureResponderEvent) => { 211 + if (longPressTriggeredRef.current) { 212 + longPressTriggeredRef.current = false 213 + return 214 + } 215 + onPressOuter?.(e) 216 + }, 217 + [onPressOuter], 218 + ) 219 + const onPointerDown = useCallback( 220 + (e: PointerEvent) => { 221 + if (onLongPressOuter && IS_WEB && !IS_WEB_TOUCH_DEVICE) { 222 + clearLongPressTimer() 223 + longPressTriggeredRef.current = false 224 + longPressTimerRef.current = setTimeout(() => { 225 + longPressTriggeredRef.current = true 226 + onLongPressOuter(e as unknown as GestureResponderEvent) 227 + }, 500) 228 + } 229 + }, 230 + [clearLongPressTimer, onLongPressOuter], 231 + ) 232 + const onPointerUp = useCallback(() => { 233 + clearLongPressTimer() 234 + }, [clearLongPressTimer]) 235 + const onPointerLeave = useCallback(() => { 236 + clearLongPressTimer() 237 + }, [clearLongPressTimer]) 193 238 const onHoverIn = useCallback( 194 239 (e: MouseEvent) => { 195 240 setState(s => ({ ··· 554 599 role="button" 555 600 accessibilityHint={undefined} // optional 556 601 {...rest} 602 + {...((onLongPressOuter && IS_WEB && !IS_WEB_TOUCH_DEVICE 603 + ? { 604 + onPointerDown, 605 + onPointerUp, 606 + onPointerLeave, 607 + onContextMenu: (e: Event) => e.preventDefault(), 608 + } 609 + : {}) as any)} 557 610 // @ts-ignore - this will always be a pressable 558 611 ref={ref} 559 612 aria-label={label} ··· 576 629 ]} 577 630 onPressIn={onPressIn} 578 631 onPressOut={onPressOut} 632 + onPress={onPress} 633 + onLongPress={!IS_WEB || IS_WEB_TOUCH_DEVICE ? onLongPressOuter : undefined} 579 634 onHoverIn={onHoverIn} 580 635 onHoverOut={onHoverOut} 581 636 onFocus={onFocus}
+77 -4
src/components/EphemeralAccountSwitcher.tsx
··· 1 1 import {useMemo} from 'react' 2 + import {View} from 'react-native' 2 3 import {type AppBskyActorDefs} from '@atproto/api' 3 4 import {useLingui} from '@lingui/react/macro' 4 5 ··· 8 9 import {useDialogControl} from '#/components/Dialog' 9 10 import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' 10 11 import * as Menu from '#/components/Menu' 11 - import {type TriggerChildProps} from '#/components/Menu/types' 12 12 import * as Prompt from '#/components/Prompt' 13 13 import {IS_WEB_TOUCH_DEVICE} from '#/env' 14 14 ··· 17 17 profile?: AppBskyActorDefs.ProfileViewDetailed 18 18 } 19 19 20 + type SwitcherTriggerProps = { 21 + ref: null 22 + onPress: (() => void) | undefined 23 + onLongPress?: (() => void) | undefined 24 + onFocus: () => void 25 + onBlur: () => void 26 + onPressIn: () => void 27 + onPressOut: () => void 28 + accessibilityLabel: string 29 + accessibilityRole: 'button' 30 + } 31 + 20 32 export function EphemeralAccountSwitcher({ 21 33 selectedDid, 22 34 title, 23 35 onSelectAccount, 36 + triggerBehavior = 'press', 24 37 renderTrigger, 25 38 }: { 26 39 selectedDid: string 27 40 title: string 28 41 onSelectAccount: (account: SessionAccount) => void 42 + triggerBehavior?: 'press' | 'longPress' 29 43 renderTrigger: (args: { 30 44 currentProfile?: AppBskyActorDefs.ProfileViewDetailed 31 - triggerProps: TriggerChildProps['props'] 45 + triggerProps: SwitcherTriggerProps 32 46 }) => React.ReactNode 33 47 }) { 34 48 const {t: l} = useLingui() ··· 38 52 handles: accounts.map(acc => acc.did), 39 53 }) 40 54 const control = useDialogControl() 55 + const menuControl = Menu.useMenuControl() 41 56 const signOutPromptControl = Prompt.usePromptControl() 42 57 const profiles = data?.profiles 43 58 ··· 51 66 })), 52 67 [accounts, profiles, selectedDid], 53 68 ) 69 + const hasSwitcherAccounts = switcherAccounts.length > 0 70 + 71 + if (!hasSwitcherAccounts) { 72 + return renderTrigger({ 73 + currentProfile, 74 + triggerProps: { 75 + ref: null, 76 + onPress: undefined, 77 + onLongPress: undefined, 78 + onFocus: () => {}, 79 + onBlur: () => {}, 80 + onPressIn: () => {}, 81 + onPressOut: () => {}, 82 + accessibilityLabel: l`Switch accounts`, 83 + accessibilityRole: 'button', 84 + }, 85 + }) 86 + } 87 + 88 + if (!IS_WEB_TOUCH_DEVICE && triggerBehavior === 'longPress') { 89 + return ( 90 + <Menu.Root control={menuControl}> 91 + <Menu.Trigger label={l`Switch accounts`}> 92 + {({props}) => ( 93 + <View {...(props as any)}> 94 + {renderTrigger({ 95 + currentProfile, 96 + triggerProps: { 97 + ref: null, 98 + onPress: undefined, 99 + onLongPress: () => menuControl.open(), 100 + onFocus: () => {}, 101 + onBlur: () => {}, 102 + onPressIn: () => {}, 103 + onPressOut: () => {}, 104 + accessibilityLabel: l`Switch accounts`, 105 + accessibilityRole: 'button', 106 + }, 107 + })} 108 + </View> 109 + )} 110 + </Menu.Trigger> 111 + <SwitchMenuItems 112 + accounts={switcherAccounts} 113 + signOutPromptControl={signOutPromptControl} 114 + showExtraButtons={false} 115 + showAddAccount={false} 116 + title={title} 117 + onSelectAccount={onSelectAccount} 118 + /> 119 + </Menu.Root> 120 + ) 121 + } 54 122 55 123 if (IS_WEB_TOUCH_DEVICE) { 124 + const openProps = 125 + triggerBehavior === 'longPress' 126 + ? {onPress: undefined, onLongPress: control.open} 127 + : {onPress: control.open, onLongPress: undefined} 128 + 56 129 return ( 57 130 <> 58 131 {renderTrigger({ 59 132 currentProfile, 60 133 triggerProps: { 61 134 ref: null, 62 - onPress: control.open, 135 + ...openProps, 63 136 onFocus: () => {}, 64 137 onBlur: () => {}, 65 138 onPressIn: () => {}, ··· 87 160 {({props}) => 88 161 renderTrigger({ 89 162 currentProfile, 90 - triggerProps: props, 163 + triggerProps: props as SwitcherTriggerProps, 91 164 }) 92 165 } 93 166 </Menu.Trigger>
+3
src/components/PostControls/BookmarkButton.tsx
··· 22 22 big, 23 23 logContext, 24 24 hitSlop, 25 + onLongPress, 25 26 }: { 26 27 post: Shadow<AppBskyFeedDefs.PostView> 27 28 big?: boolean 28 29 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 29 30 hitSlop?: Insets 31 + onLongPress?: () => void 30 32 }): React.ReactNode { 31 33 const t = useTheme() 32 34 const ax = useAnalytics() ··· 144 146 : _(msg`Add to saved posts`) 145 147 } 146 148 onPress={onHandlePress} 149 + onLongPress={onLongPress} 147 150 hitSlop={hitSlop}> 148 151 <PostControlButtonIcon icon={isBookmarked ? BookmarkFilled : Bookmark} /> 149 152 </PostControlButton>
+9 -9
src/components/PostControls/RepostButton.tsx
··· 24 24 repostCount?: number 25 25 onRepost: () => void 26 26 onQuote: () => void 27 + onLongPress?: () => void 27 28 big?: boolean 28 29 embeddingDisabled: boolean 29 30 } ··· 33 34 repostCount, 34 35 onRepost, 35 36 onQuote, 37 + onLongPress, 36 38 big, 37 39 embeddingDisabled, 38 40 }: Props): React.ReactNode => { ··· 44 46 45 47 const onPress = () => requireAuth(() => dialogControl.open()) 46 48 47 - const onLongPress = () => 49 + const onDefaultLongPress = () => 48 50 requireAuth(() => { 49 51 if (embeddingDisabled) { 50 52 dialogControl.open() ··· 54 56 }) 55 57 56 58 return ( 57 - <> 59 + <View> 58 60 <PostControlButton 59 61 testID="repostBtn" 60 62 active={isReposted} 61 63 activeColor={t.palette.positive_500} 62 64 big={big} 63 65 onPress={onPress} 64 - onLongPress={onLongPress} 66 + onLongPress={onLongPress ?? onDefaultLongPress} 65 67 label={ 66 68 isReposted 67 69 ? _( ··· 103 105 embeddingDisabled={embeddingDisabled} 104 106 /> 105 107 </Dialog.Outer> 106 - </> 108 + </View> 107 109 ) 108 110 } 109 111 RepostButton = memo(RepostButton) 110 112 export {RepostButton} 111 113 112 - let RepostButtonDialogInner = ({ 114 + export const RepostButtonDialogInner = memo(function RepostButtonDialogInner({ 113 115 isReposted, 114 116 onRepost, 115 117 onQuote, ··· 119 121 onRepost: () => void 120 122 onQuote: () => void 121 123 embeddingDisabled: boolean 122 - }): React.ReactNode => { 124 + }): React.ReactNode { 123 125 const t = useTheme() 124 126 const {_} = useLingui() 125 127 const playHaptic = useHaptics() ··· 213 215 </View> 214 216 </Dialog.ScrollableInner> 215 217 ) 216 - } 217 - RepostButtonDialogInner = memo(RepostButtonDialogInner) 218 - export {RepostButtonDialogInner} 218 + })
+3
src/components/PostControls/RepostButton.web.tsx
··· 19 19 repostCount?: number 20 20 onRepost: () => void 21 21 onQuote: () => void 22 + onLongPress?: () => void 22 23 big?: boolean 23 24 embeddingDisabled: boolean 24 25 } ··· 28 29 repostCount, 29 30 onRepost, 30 31 onQuote, 32 + onLongPress, 31 33 big, 32 34 embeddingDisabled, 33 35 }: Props) => { ··· 49 51 activeColor={t.palette.positive_500} 50 52 label={props.accessibilityLabel} 51 53 big={big} 54 + onLongPress={onLongPress} 52 55 {...props}> 53 56 <PostControlButtonIcon icon={Repost} /> 54 57 {typeof repostCount !== 'undefined' && repostCount > 0 && (
+349 -117
src/components/PostControls/index.tsx
··· 6 6 type AppBskyFeedThreadgate, 7 7 type RichText as RichTextAPI, 8 8 } from '@atproto/api' 9 - import {plural} from '@lingui/core/macro' 9 + import {msg, plural} from '@lingui/core/macro' 10 10 import {useLingui} from '@lingui/react/macro' 11 11 12 12 import {CountWheel} from '#/lib/custom-animations/CountWheel' ··· 24 24 usePostLikeMutationQueue, 25 25 usePostRepostMutationQueue, 26 26 } from '#/state/queries/post' 27 - import {useRequireAuth} from '#/state/session' 27 + import {useRequireAuth, useSession} from '#/state/session' 28 28 import { 29 29 ProgressGuideAction, 30 30 useProgressGuideControls, ··· 35 35 import {useFormatPostStatCount} from '#/components/PostControls/util' 36 36 import * as Skele from '#/components/Skeleton' 37 37 import * as Toast from '#/components/Toast' 38 + import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher' 38 39 import {useAnalytics} from '#/analytics' 40 + import {useRunWithEphemeralAgent} from '../hooks/useRunWithEphemeralAgent' 39 41 import {useAutoLikeOnRepost} from '../../state/preferences/auto-like-on-repost.tsx' 40 42 import {BookmarkButton} from './BookmarkButton' 41 43 import { ··· 85 87 const {t: l} = useLingui() 86 88 const {openComposer} = useOpenComposer() 87 89 const {feedDescriptor} = useFeedFeedbackContext() 90 + const {accounts, currentAccount} = useSession() 88 91 const getPost = useGetPost() 92 + const runWithEphemeralAgent = useRunWithEphemeralAgent() 89 93 const [queueLike, queueUnlike] = usePostLikeMutationQueue( 90 94 post, 91 95 viaRepost, ··· 242 246 }) 243 247 } 244 248 249 + const onReplyAsAccount = (accountDid: string) => { 250 + setTimeout(() => { 251 + ax.metric('post:clickReply', { 252 + uri: post.uri, 253 + authorDid: post.author.did, 254 + logContext, 255 + feedDescriptor, 256 + }) 257 + openComposer({ 258 + activeAccountDid: accountDid, 259 + replyTo: { 260 + uri: post.uri, 261 + cid: post.cid, 262 + text: record.text || '', 263 + author: post.author, 264 + embed: post.embed, 265 + langs: record.langs, 266 + }, 267 + onPost: onPostReply, 268 + logContext: 'PostReply', 269 + }) 270 + }, 0) 271 + } 272 + 245 273 const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({ 246 274 variant, 247 275 big, 248 276 gtPhone, 249 277 }) 278 + const hasAlternateAccounts = accounts.some( 279 + account => account.did !== currentAccount?.did, 280 + ) 281 + 282 + const onSelectLikeAccount = async (account: (typeof accounts)[number]) => { 283 + try { 284 + const wasLiked = await runWithEphemeralAgent(account, async agent => { 285 + const res = await agent.getPosts({uris: [post.uri]}) 286 + const target = res.data.posts[0] 287 + const likeUri = target?.viewer?.like 288 + 289 + if (likeUri) { 290 + await agent.deleteLike(likeUri) 291 + return true 292 + } 293 + 294 + await agent.like(post.uri, post.cid) 295 + return false 296 + }) 297 + 298 + Toast.show( 299 + wasLiked 300 + ? l`Removed like as @${account.handle}` 301 + : l`Liked as @${account.handle}`, 302 + ) 303 + } catch (e) { 304 + Toast.show(l`An issue occurred, please try again.`, { 305 + type: 'error', 306 + }) 307 + } 308 + } 309 + 310 + const onSelectRepostAccount = async (account: (typeof accounts)[number]) => { 311 + try { 312 + const wasReposted = await runWithEphemeralAgent(account, async agent => { 313 + const res = await agent.getPosts({uris: [post.uri]}) 314 + const target = res.data.posts[0] 315 + const repostUri = target?.viewer?.repost 316 + 317 + if (repostUri) { 318 + await agent.deleteRepost(repostUri) 319 + return true 320 + } 321 + 322 + await agent.repost(post.uri, post.cid) 323 + return false 324 + }) 325 + 326 + Toast.show( 327 + wasReposted 328 + ? l`Removed repost as @${account.handle}` 329 + : l`Reposted as @${account.handle}`, 330 + ) 331 + } catch (e) { 332 + Toast.show(l`An issue occurred, please try again.`, { 333 + type: 'error', 334 + }) 335 + } 336 + } 337 + 338 + const onSelectBookmarkAccount = async ( 339 + account: (typeof accounts)[number], 340 + ) => { 341 + try { 342 + const wasBookmarked = await runWithEphemeralAgent(account, async agent => { 343 + const res = await agent.getPosts({uris: [post.uri]}) 344 + const target = res.data.posts[0] 345 + 346 + if (target?.viewer?.bookmarked) { 347 + await agent.app.bsky.bookmark.deleteBookmark({uri: post.uri}) 348 + return true 349 + } 350 + 351 + await agent.app.bsky.bookmark.createBookmark({ 352 + uri: post.uri, 353 + cid: post.cid, 354 + }) 355 + return false 356 + }) 357 + 358 + Toast.show( 359 + wasBookmarked 360 + ? l`Removed save as @${account.handle}` 361 + : l`Saved as @${account.handle}`, 362 + ) 363 + } catch (e) { 364 + Toast.show(l`An issue occurred, please try again.`, { 365 + type: 'error', 366 + }) 367 + } 368 + } 369 + 370 + const renderLikeButton = (onLongPress?: () => void) => ( 371 + <PostControlButton 372 + testID="likeBtn" 373 + big={big} 374 + active={Boolean(post.viewer?.like)} 375 + activeColor={t.palette.pink} 376 + onPress={() => requireAuth(() => onPressToggleLike())} 377 + onLongPress={onLongPress} 378 + label={ 379 + post.viewer?.like 380 + ? l({ 381 + message: `Unlike (${plural(post.likeCount || 0, { 382 + one: '# like', 383 + other: '# likes', 384 + })})`, 385 + comment: 386 + 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', 387 + }) 388 + : l({ 389 + message: `Like (${plural(post.likeCount || 0, { 390 + one: '# like', 391 + other: '# likes', 392 + })})`, 393 + comment: 394 + 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', 395 + }) 396 + }> 397 + <AnimatedLikeIcon 398 + isLiked={Boolean(post.viewer?.like)} 399 + big={big} 400 + hasBeenToggled={hasLikeIconBeenToggled} 401 + /> 402 + {!disableLikesMetrics ? ( 403 + <CountWheel 404 + count={post.likeCount ?? 0} 405 + isToggled={Boolean(post.viewer?.like)} 406 + hasBeenToggled={hasLikeIconBeenToggled} 407 + renderCount={({count}) => ( 408 + <PostControlButtonText testID="likeCount"> 409 + {formatPostStatCount(count)} 410 + </PostControlButtonText> 411 + )} 412 + /> 413 + ) : null} 414 + </PostControlButton> 415 + ) 250 416 251 417 return ( 252 - <View 253 - style={[ 254 - a.flex_row, 255 - a.justify_between, 256 - a.align_center, 257 - !big && a.pt_2xs, 258 - a.gap_md, 259 - style, 260 - ]}> 261 - <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}> 418 + <> 419 + <View 420 + style={[ 421 + a.flex_row, 422 + a.justify_between, 423 + a.align_center, 424 + !big && a.pt_2xs, 425 + a.gap_md, 426 + style, 427 + ]}> 428 + <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}> 262 429 <View 263 430 style={[ 264 431 a.flex_1, ··· 266 433 {marginLeft: big ? -2 : -6}, 267 434 replyDisabled ? {opacity: 0.6} : undefined, 268 435 ]}> 269 - <PostControlButton 270 - testID="replyBtn" 271 - onPress={ 272 - !replyDisabled 273 - ? () => 436 + {currentAccount && hasAlternateAccounts && !replyDisabled ? ( 437 + <EphemeralAccountSwitcher 438 + selectedDid={currentAccount.did} 439 + title={l`Reply as`} 440 + triggerBehavior="longPress" 441 + onSelectAccount={account => { 442 + onReplyAsAccount(account.did) 443 + }} 444 + renderTrigger={({triggerProps}) => ( 445 + <PostControlButton 446 + testID="replyBtn" 447 + onPress={() => 274 448 requireAuth(() => { 275 449 ax.metric('post:clickReply', { 276 450 uri: post.uri, ··· 280 454 }) 281 455 onPressReply() 282 456 }) 283 - : undefined 284 - } 285 - label={l({ 286 - message: `Reply (${plural(post.replyCount || 0, { 287 - one: '# reply', 288 - other: '# replies', 289 - })})`, 290 - comment: 291 - 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 292 - })} 293 - big={big}> 294 - <PostControlButtonIcon icon={Bubble} /> 295 - {typeof post.replyCount !== 'undefined' && 296 - post.replyCount > 0 && 297 - !disableReplyMetrics && ( 298 - <PostControlButtonText> 299 - {formatPostStatCount(post.replyCount)} 300 - </PostControlButtonText> 301 - )} 302 - </PostControlButton> 303 - </View> 304 - <View style={[a.flex_1, a.align_start]}> 305 - <RepostButton 306 - isReposted={!!post.viewer?.repost} 307 - repostCount={ 308 - (!disableRepostsMetrics ? (post.repostCount ?? 0) : 0) + 309 - (!disableQuotesMetrics ? (post.quoteCount ?? 0) : 0) 310 - } 311 - onRepost={() => void onRepost()} 312 - onQuote={onQuote} 313 - big={big} 314 - embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 315 - /> 316 - </View> 317 - <View style={[a.flex_1, a.align_start]}> 318 - <PostControlButton 319 - testID="likeBtn" 320 - big={big} 321 - active={Boolean(post.viewer?.like)} 322 - activeColor={t.palette.pink} 323 - onPress={() => requireAuth(() => onPressToggleLike())} 324 - label={ 325 - post.viewer?.like 326 - ? l({ 327 - message: `Unlike (${plural(post.likeCount || 0, { 328 - one: '# like', 329 - other: '# likes', 457 + } 458 + onLongPress={triggerProps.onLongPress} 459 + label={l({ 460 + message: `Reply (${plural(post.replyCount || 0, { 461 + one: '# reply', 462 + other: '# replies', 330 463 })})`, 331 464 comment: 332 - 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', 333 - }) 334 - : l({ 335 - message: `Like (${plural(post.likeCount || 0, { 336 - one: '# like', 337 - other: '# likes', 338 - })})`, 339 - comment: 340 - 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', 341 - }) 342 - }> 343 - <AnimatedLikeIcon 344 - isLiked={Boolean(post.viewer?.like)} 345 - big={big} 346 - hasBeenToggled={hasLikeIconBeenToggled} 465 + 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 466 + })} 467 + big={big}> 468 + <PostControlButtonIcon icon={Bubble} /> 469 + {typeof post.replyCount !== 'undefined' && 470 + post.replyCount > 0 && 471 + !disableReplyMetrics && ( 472 + <PostControlButtonText> 473 + {formatPostStatCount(post.replyCount)} 474 + </PostControlButtonText> 475 + )} 476 + </PostControlButton> 477 + )} 347 478 /> 348 - {!disableLikesMetrics ? ( 349 - <CountWheel 350 - count={post.likeCount ?? 0} 351 - isToggled={Boolean(post.viewer?.like)} 352 - hasBeenToggled={hasLikeIconBeenToggled} 353 - renderCount={({count}) => ( 354 - <PostControlButtonText testID="likeCount"> 355 - {formatPostStatCount(count)} 479 + ) : ( 480 + <PostControlButton 481 + testID="replyBtn" 482 + onPress={ 483 + !replyDisabled 484 + ? () => 485 + requireAuth(() => { 486 + ax.metric('post:clickReply', { 487 + uri: post.uri, 488 + authorDid: post.author.did, 489 + logContext, 490 + feedDescriptor, 491 + }) 492 + onPressReply() 493 + }) 494 + : undefined 495 + } 496 + label={l({ 497 + message: `Reply (${plural(post.replyCount || 0, { 498 + one: '# reply', 499 + other: '# replies', 500 + })})`, 501 + comment: 502 + 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 503 + })} 504 + big={big}> 505 + <PostControlButtonIcon icon={Bubble} /> 506 + {typeof post.replyCount !== 'undefined' && 507 + post.replyCount > 0 && 508 + !disableReplyMetrics && ( 509 + <PostControlButtonText> 510 + {formatPostStatCount(post.replyCount)} 356 511 </PostControlButtonText> 357 512 )} 513 + </PostControlButton> 514 + )} 515 + </View> 516 + <View style={[a.flex_1, a.align_start]}> 517 + {currentAccount && hasAlternateAccounts ? ( 518 + <EphemeralAccountSwitcher 519 + selectedDid={currentAccount.did} 520 + title={l`Repost as`} 521 + triggerBehavior="longPress" 522 + onSelectAccount={account => { 523 + void onSelectRepostAccount(account) 524 + }} 525 + renderTrigger={({triggerProps}) => ( 526 + <RepostButton 527 + isReposted={!!post.viewer?.repost} 528 + repostCount={ 529 + (!disableRepostsMetrics ? (post.repostCount ?? 0) : 0) + 530 + (!disableQuotesMetrics ? (post.quoteCount ?? 0) : 0) 531 + } 532 + onRepost={() => void onRepost()} 533 + onQuote={onQuote} 534 + onLongPress={triggerProps.onLongPress} 535 + big={big} 536 + embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 537 + /> 538 + )} 358 539 /> 359 - ) : null} 360 - </PostControlButton> 540 + ) : ( 541 + <RepostButton 542 + isReposted={!!post.viewer?.repost} 543 + repostCount={ 544 + (!disableRepostsMetrics ? (post.repostCount ?? 0) : 0) + 545 + (!disableQuotesMetrics ? (post.quoteCount ?? 0) : 0) 546 + } 547 + onRepost={() => void onRepost()} 548 + onQuote={onQuote} 549 + big={big} 550 + embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 551 + /> 552 + )} 553 + </View> 554 + <View style={[a.flex_1, a.align_start]}> 555 + {currentAccount && hasAlternateAccounts ? ( 556 + <EphemeralAccountSwitcher 557 + selectedDid={currentAccount.did} 558 + title={l`Like as`} 559 + triggerBehavior="longPress" 560 + onSelectAccount={account => { 561 + void onSelectLikeAccount(account) 562 + }} 563 + renderTrigger={({triggerProps}) => 564 + renderLikeButton(triggerProps.onLongPress) 565 + } 566 + /> 567 + ) : ( 568 + renderLikeButton() 569 + )} 361 570 </View> 362 571 {/* Spacer! */} 363 572 <View /> 364 - </View> 365 - <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}> 366 - <BookmarkButton 367 - post={post} 368 - big={big} 369 - logContext={logContext} 370 - hitSlop={{ 371 - right: secondaryControlSpacingStyles.gap / 2, 372 - }} 373 - /> 573 + </View> 574 + <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}> 575 + {currentAccount && hasAlternateAccounts ? ( 576 + <EphemeralAccountSwitcher 577 + selectedDid={currentAccount.did} 578 + title={l`Save as`} 579 + triggerBehavior="longPress" 580 + onSelectAccount={account => { 581 + void onSelectBookmarkAccount(account) 582 + }} 583 + renderTrigger={({triggerProps}) => ( 584 + <BookmarkButton 585 + post={post} 586 + big={big} 587 + logContext={logContext} 588 + onLongPress={triggerProps.onLongPress} 589 + hitSlop={{ 590 + right: secondaryControlSpacingStyles.gap / 2, 591 + }} 592 + /> 593 + )} 594 + /> 595 + ) : ( 596 + <BookmarkButton 597 + post={post} 598 + big={big} 599 + logContext={logContext} 600 + hitSlop={{ 601 + right: secondaryControlSpacingStyles.gap / 2, 602 + }} 603 + /> 604 + )} 374 605 <ShareMenuButton 375 606 testID="postShareBtn" 376 607 post={post} ··· 386 617 }} 387 618 logContext={logContext} 388 619 /> 389 - <PostMenuButton 390 - testID="postDropdownBtn" 391 - post={post} 392 - postFeedContext={feedContext} 393 - postReqId={reqId} 394 - big={big} 395 - record={record} 396 - richText={richText} 397 - timestamp={post.indexedAt} 398 - threadgateRecord={threadgateRecord} 399 - onShowLess={onShowLess} 400 - hitSlop={{ 401 - left: secondaryControlSpacingStyles.gap / 2, 402 - }} 403 - logContext={logContext} 404 - forceGoogleTranslate={forceGoogleTranslate} 405 - /> 620 + <PostMenuButton 621 + testID="postDropdownBtn" 622 + post={post} 623 + postFeedContext={feedContext} 624 + postReqId={reqId} 625 + big={big} 626 + record={record} 627 + richText={richText} 628 + timestamp={post.indexedAt} 629 + threadgateRecord={threadgateRecord} 630 + onShowLess={onShowLess} 631 + hitSlop={{ 632 + left: secondaryControlSpacingStyles.gap / 2, 633 + }} 634 + logContext={logContext} 635 + forceGoogleTranslate={forceGoogleTranslate} 636 + /> 637 + </View> 406 638 </View> 407 - </View> 639 + </> 408 640 ) 409 641 } 410 642 PostControls = memo(PostControls)
+60 -29
src/components/ProfileCard.tsx
··· 38 38 type ButtonProps, 39 39 ButtonText, 40 40 } from '#/components/Button' 41 + import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher' 41 42 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 42 43 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 43 44 import {Link as InternalLink, type LinkProps} from '#/components/Link' ··· 49 50 import {type Metrics} from '#/analytics' 50 51 import {useActorStatus} from '#/features/liveNow' 51 52 import type * as bsky from '#/types/bsky' 53 + import {useEphemeralFollowAction} from './hooks/useEphemeralFollowAction' 52 54 53 55 export function Default({ 54 56 profile, ··· 479 481 contextProfileDid, 480 482 ...rest 481 483 }: FollowButtonProps) { 484 + const {currentAccount, accounts} = useSession() 482 485 const {t: l} = useLingui() 483 486 const profile = useProfileShadow(profileUnshadowed) 484 487 const moderation = moderateProfile(profile, moderationOpts) ··· 489 492 contextProfileDid, 490 493 ) 491 494 const isRound = Boolean(rest.shape && rest.shape === 'round') 495 + const onSelectEphemeralAccount = useEphemeralFollowAction({ 496 + profile, 497 + logContext, 498 + onFollow, 499 + }) 500 + const hasAlternateAccounts = accounts.some( 501 + account => account.did !== currentAccount?.did, 502 + ) 492 503 493 504 const onPressFollow = async (e: GestureResponderEvent) => { 494 505 e.preventDefault() ··· 561 572 profile.viewer.blockingByList 562 573 ) 563 574 return null 575 + const viewer = profile.viewer 576 + 577 + const renderFollowButton = (onLongPress?: () => void) => 578 + viewer.following ? ( 579 + <Button 580 + label={unfollowLabel} 581 + size="small" 582 + variant="solid" 583 + color="secondary" 584 + {...rest} 585 + onLongPress={onLongPress} 586 + onPress={(e: GestureResponderEvent) => { 587 + void onPressUnfollow(e) 588 + }}> 589 + {withIcon && ( 590 + <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> 591 + )} 592 + {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>} 593 + </Button> 594 + ) : ( 595 + <Button 596 + label={followLabel} 597 + size="small" 598 + variant="solid" 599 + color={colorInverted ? 'secondary_inverted' : 'primary'} 600 + {...rest} 601 + onLongPress={onLongPress} 602 + onPress={(e: GestureResponderEvent) => { 603 + void onPressFollow(e) 604 + }}> 605 + {withIcon && ( 606 + <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> 607 + )} 608 + {isRound ? null : <ButtonText>{followLabel}</ButtonText>} 609 + </Button> 610 + ) 564 611 565 612 return ( 566 613 <View> 567 - {profile.viewer.following ? ( 568 - <Button 569 - label={unfollowLabel} 570 - size="small" 571 - variant="solid" 572 - color="secondary" 573 - {...rest} 574 - onPress={(e: GestureResponderEvent) => { 575 - void onPressUnfollow(e) 576 - }}> 577 - {withIcon && ( 578 - <ButtonIcon icon={Check} position={isRound ? undefined : 'left'} /> 579 - )} 580 - {isRound ? null : <ButtonText>{unfollowLabel}</ButtonText>} 581 - </Button> 614 + {currentAccount && hasAlternateAccounts ? ( 615 + <EphemeralAccountSwitcher 616 + selectedDid={currentAccount.did} 617 + title={l`Follow as`} 618 + triggerBehavior="longPress" 619 + onSelectAccount={account => { 620 + void onSelectEphemeralAccount(account) 621 + }} 622 + renderTrigger={({triggerProps}) => 623 + renderFollowButton(triggerProps.onLongPress) 624 + } 625 + /> 582 626 ) : ( 583 - <Button 584 - label={followLabel} 585 - size="small" 586 - variant="solid" 587 - color={colorInverted ? 'secondary_inverted' : 'primary'} 588 - {...rest} 589 - onPress={(e: GestureResponderEvent) => { 590 - void onPressFollow(e) 591 - }}> 592 - {withIcon && ( 593 - <ButtonIcon icon={Plus} position={isRound ? undefined : 'left'} /> 594 - )} 595 - {isRound ? null : <ButtonText>{followLabel}</ButtonText>} 596 - </Button> 627 + renderFollowButton() 597 628 )} 598 629 </View> 599 630 )
+80
src/components/hooks/useEphemeralFollowAction.ts
··· 1 + import {useCallback} from 'react' 2 + import {type AppBskyActorDefs} from '@atproto/api' 3 + import {msg} from '@lingui/core/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {sanitizeDisplayName} from '#/lib/strings/display-names' 7 + import {logger} from '#/logger' 8 + import {type Shadow} from '#/state/cache/types' 9 + import {type SessionAccount} from '#/state/session' 10 + import * as Toast from '#/components/Toast' 11 + import {type Metrics} from '#/analytics/metrics' 12 + import type * as bsky from '#/types/bsky' 13 + import {useRunWithEphemeralAgent} from './useRunWithEphemeralAgent' 14 + 15 + export function useEphemeralFollowAction({ 16 + profile, 17 + logContext: _logContext, 18 + onFollow, 19 + onUnfollow, 20 + }: { 21 + profile: Shadow<bsky.profile.AnyProfileView> 22 + logContext: Metrics['profile:follow']['logContext'] & 23 + Metrics['profile:unfollow']['logContext'] 24 + onFollow?: () => void 25 + onUnfollow?: () => void 26 + }) { 27 + const {_} = useLingui() 28 + const runWithEphemeralAgent = useRunWithEphemeralAgent() 29 + 30 + return useCallback( 31 + async (account: SessionAccount) => { 32 + try { 33 + const result = await runWithEphemeralAgent(account, async agent => { 34 + const res = await agent.getProfile({actor: profile.did}) 35 + const target = 36 + res.data as AppBskyActorDefs.ProfileViewDetailed 37 + const followingUri = target.viewer?.following 38 + 39 + if (followingUri) { 40 + await agent.deleteFollow(followingUri) 41 + return {followed: false} 42 + } 43 + 44 + await agent.follow(profile.did) 45 + return {followed: true} 46 + }) 47 + 48 + if (result.followed) { 49 + onFollow?.() 50 + Toast.show( 51 + _( 52 + msg`Following ${sanitizeDisplayName( 53 + profile.displayName || profile.handle, 54 + )} as @${account.handle}`, 55 + ), 56 + ) 57 + } else { 58 + onUnfollow?.() 59 + Toast.show( 60 + _( 61 + msg`No longer following ${sanitizeDisplayName( 62 + profile.displayName || profile.handle, 63 + )} as @${account.handle}`, 64 + ), 65 + ) 66 + } 67 + } catch (e) { 68 + logger.error('useEphemeralFollowAction: failed to toggle follow', { 69 + message: String(e), 70 + targetDid: profile.did, 71 + accountDid: account.did, 72 + }) 73 + Toast.show(_(msg`An issue occurred, please try again.`), { 74 + type: 'error', 75 + }) 76 + } 77 + }, 78 + [_, onFollow, onUnfollow, profile, runWithEphemeralAgent], 79 + ) 80 + }
+19
src/components/hooks/useRunWithEphemeralAgent.ts
··· 1 + import {useCallback} from 'react' 2 + import {type BskyAgent} from '@atproto/api' 3 + 4 + import {type SessionAccount, useSessionApi} from '#/state/session' 5 + 6 + export function useRunWithEphemeralAgent() { 7 + const {createEphemeralAgent} = useSessionApi() 8 + 9 + return useCallback( 10 + async <T>( 11 + account: SessionAccount, 12 + fn: (agent: BskyAgent) => Promise<T>, 13 + ): Promise<T> => { 14 + const agent = await createEphemeralAgent(account) 15 + return await fn(agent) 16 + }, 17 + [createEphemeralAgent], 18 + ) 19 + }
+31 -2
src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx
··· 12 12 useProfileFollowMutationQueue, 13 13 useProfileQuery, 14 14 } from '#/state/queries/profile' 15 - import {useRequireAuth} from '#/state/session' 15 + import {useRequireAuth, useSession} from '#/state/session' 16 16 import {atoms as a, useBreakpoints} from '#/alf' 17 17 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 18 + import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher' 19 + import {useEphemeralFollowAction} from '#/components/hooks/useEphemeralFollowAction' 18 20 import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 19 21 import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' 20 22 import * as Toast from '#/components/Toast' ··· 64 66 const {_} = useLingui() 65 67 const {gtMobile} = useBreakpoints() 66 68 const profile = useProfileShadow(profileUnshadowed) 69 + const {accounts, currentAccount} = useSession() 67 70 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 68 71 profile, 69 72 'PostThreadItem', 70 73 ) 71 74 const requireAuth = useRequireAuth() 75 + const onSelectEphemeralAccount = useEphemeralFollowAction({ 76 + profile, 77 + logContext: 'PostThreadItem', 78 + }) 72 79 73 80 const isFollowing = !!profile.viewer?.following 74 81 const isFollowedBy = !!profile.viewer?.followedBy 75 82 const [wasFollowing, setWasFollowing] = useState<boolean>(isFollowing) 76 83 77 84 const enableSquareButtons = useEnableSquareButtons() 85 + const hasAlternateAccounts = accounts.some( 86 + account => account.did !== currentAccount?.did, 87 + ) 78 88 79 89 // This prevents the button from disappearing as soon as we follow. 80 90 const showFollowBtn = useMemo( ··· 141 151 142 152 if (!showFollowBtn) return null 143 153 144 - return ( 154 + const renderFollowButton = (onLongPress?: () => void) => ( 145 155 <Button 146 156 testID="followBtn" 147 157 label={_(msg`Follow ${profile.handle}`)} 158 + onLongPress={onLongPress} 148 159 onPress={onPress} 149 160 size="small" 150 161 color={isFollowing ? 'secondary' : 'secondary_inverted'} ··· 166 177 )} 167 178 </ButtonText> 168 179 </Button> 180 + ) 181 + 182 + return ( 183 + currentAccount && hasAlternateAccounts ? ( 184 + <EphemeralAccountSwitcher 185 + selectedDid={currentAccount.did} 186 + title={_(msg`Follow as`)} 187 + triggerBehavior="longPress" 188 + onSelectAccount={account => { 189 + void onSelectEphemeralAccount(account) 190 + }} 191 + renderTrigger={({triggerProps}) => 192 + renderFollowButton(triggerProps.onLongPress) 193 + } 194 + /> 195 + ) : ( 196 + renderFollowButton() 197 + ) 169 198 ) 170 199 }
+85 -27
src/screens/Profile/Header/ProfileHeaderStandard.tsx
··· 42 42 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 43 43 import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' 44 44 import {useDialogControl} from '#/components/Dialog' 45 + import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher' 45 46 import {MessageProfileButton} from '#/components/dms/MessageProfileButton' 46 47 import {CalendarDays_Stroke2_Corner0_Rounded as CalendarDays} from '#/components/icons/CalendarDays' 47 48 import {Globe_Stroke2_Corner0_Rounded as Globe} from '#/components/icons/Globe' ··· 64 65 import {ProfileHeaderMetrics} from './Metrics' 65 66 import {ProfileHeaderShell} from './Shell' 66 67 import {ProfileHeaderSuggestedFollows} from './SuggestedFollows' 68 + import {useEphemeralFollowAction} from '#/components/hooks/useEphemeralFollowAction' 67 69 68 70 interface Props { 69 71 profile: AppBskyActorDefs.ProfileViewDetailed ··· 318 320 minimal?: boolean 319 321 }) { 320 322 const {_} = useLingui() 321 - const {hasSession, currentAccount} = useSession() 323 + const {accounts, hasSession, currentAccount} = useSession() 322 324 const playHaptic = useHaptics() 323 325 const requireAuth = useRequireAuth() 324 326 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( ··· 329 331 const editProfileControl = useDialogControl() 330 332 const unblockPromptControl = Prompt.usePromptControl() 331 333 const hideScaryFollowButtons = useHideScaryFollowButtons() 334 + const onSelectEphemeralAccount = useEphemeralFollowAction({ 335 + profile, 336 + logContext: 'ProfileHeader', 337 + onFollow, 338 + onUnfollow, 339 + }) 340 + const hasAlternateAccounts = accounts.some( 341 + account => account.did !== currentAccount?.did, 342 + ) 332 343 333 344 const isMe = currentAccount?.did === profile.did 334 345 ··· 462 473 463 474 {(!minimal || !profile.viewer?.following) && 464 475 !(minimal && hideScaryFollowButtons) && ( 465 - <Button 466 - testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'} 467 - size="small" 468 - color={profile.viewer?.following ? 'secondary' : 'primary'} 469 - label={ 470 - profile.viewer?.following 471 - ? _(msg`Unfollow ${profile.handle}`) 472 - : _(msg`Follow ${profile.handle}`) 473 - } 474 - onPress={ 475 - profile.viewer?.following ? onPressUnfollow : onPressFollow 476 - }> 477 - {!profile.viewer?.following && <ButtonIcon icon={Plus} />} 478 - <ButtonText> 479 - {profile.viewer?.following ? ( 480 - profile.viewer?.followedBy ? ( 481 - <Trans>Mutuals</Trans> 476 + (currentAccount && hasAlternateAccounts ? ( 477 + <EphemeralAccountSwitcher 478 + selectedDid={currentAccount.did} 479 + title={_(msg`Follow as`)} 480 + triggerBehavior="longPress" 481 + onSelectAccount={account => { 482 + void onSelectEphemeralAccount(account) 483 + }} 484 + renderTrigger={({triggerProps}) => ( 485 + <Button 486 + testID={ 487 + profile.viewer?.following ? 'unfollowBtn' : 'followBtn' 488 + } 489 + size="small" 490 + color={ 491 + profile.viewer?.following ? 'secondary' : 'primary' 492 + } 493 + label={ 494 + profile.viewer?.following 495 + ? _(msg`Unfollow ${profile.handle}`) 496 + : _(msg`Follow ${profile.handle}`) 497 + } 498 + onLongPress={triggerProps.onLongPress} 499 + onPress={ 500 + profile.viewer?.following 501 + ? onPressUnfollow 502 + : onPressFollow 503 + }> 504 + {!profile.viewer?.following && <ButtonIcon icon={Plus} />} 505 + <ButtonText> 506 + {profile.viewer?.following ? ( 507 + profile.viewer?.followedBy ? ( 508 + <Trans>Mutuals</Trans> 509 + ) : ( 510 + <Trans>Following</Trans> 511 + ) 512 + ) : profile.viewer?.followedBy ? ( 513 + <Trans>Follow back</Trans> 514 + ) : ( 515 + <Trans>Follow</Trans> 516 + )} 517 + </ButtonText> 518 + </Button> 519 + )} 520 + /> 521 + ) : ( 522 + <Button 523 + testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'} 524 + size="small" 525 + color={profile.viewer?.following ? 'secondary' : 'primary'} 526 + label={ 527 + profile.viewer?.following 528 + ? _(msg`Unfollow ${profile.handle}`) 529 + : _(msg`Follow ${profile.handle}`) 530 + } 531 + onPress={ 532 + profile.viewer?.following ? onPressUnfollow : onPressFollow 533 + }> 534 + {!profile.viewer?.following && <ButtonIcon icon={Plus} />} 535 + <ButtonText> 536 + {profile.viewer?.following ? ( 537 + profile.viewer?.followedBy ? ( 538 + <Trans>Mutuals</Trans> 539 + ) : ( 540 + <Trans>Following</Trans> 541 + ) 542 + ) : profile.viewer?.followedBy ? ( 543 + <Trans>Follow back</Trans> 482 544 ) : ( 483 - <Trans>Following</Trans> 484 - ) 485 - ) : profile.viewer?.followedBy ? ( 486 - <Trans>Follow back</Trans> 487 - ) : ( 488 - <Trans>Follow</Trans> 489 - )} 490 - </ButtonText> 491 - </Button> 545 + <Trans>Follow</Trans> 546 + )} 547 + </ButtonText> 548 + </Button> 549 + )) 492 550 )} 493 551 </> 494 552 ) : null}
+84 -29
src/screens/VideoFeed/index.tsx
··· 85 85 import {setSystemUITheme} from '#/alf/util/systemUI' 86 86 import {Button, ButtonIcon, ButtonText} from '#/components/Button' 87 87 import {Divider} from '#/components/Divider' 88 + import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher' 88 89 import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' 89 90 import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 90 91 import {EyeSlash_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/EyeSlash' ··· 100 101 import {useAnalytics} from '#/analytics' 101 102 import {IS_ANDROID} from '#/env' 102 103 import * as bsky from '#/types/bsky' 104 + import {useEphemeralFollowAction} from '#/components/hooks/useEphemeralFollowAction' 103 105 import {Scrubber, VIDEO_PLAYER_BOTTOM_INSET} from './components/Scrubber' 104 106 105 107 function createThreeVideoPlayers( ··· 723 725 const {t: l} = useLingui() 724 726 const t = useTheme() 725 727 const {openComposer} = useOpenComposer() 726 - const {currentAccount} = useSession() 728 + const {accounts, currentAccount} = useSession() 727 729 const navigation = useNavigation<NavigationProp>() 728 730 const seekingAnimationSV = useSharedValue(0) 729 731 ··· 731 733 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( 732 734 profile, 733 735 'ImmersiveVideo', 736 + ) 737 + const onSelectEphemeralAccount = useEphemeralFollowAction({ 738 + profile, 739 + logContext: 'ImmersiveVideo', 740 + }) 741 + const hasAlternateAccounts = useMemo( 742 + () => accounts.some(account => account.did !== currentAccount?.did), 743 + [accounts, currentAccount?.did], 734 744 ) 735 745 736 746 const rkey = new AtUri(post.uri).rkey ··· 839 849 {/* show button based on non-reactive version, so it doesn't hide on press */} 840 850 {post.author.did !== currentAccount?.did && 841 851 !post.author.viewer?.following && ( 842 - <Button 843 - label={ 844 - profile.viewer?.following 845 - ? l`Following ${handle}` 846 - : l`Follow ${handle}` 847 - } 848 - accessibilityHint={ 849 - profile.viewer?.following ? l`Unfollows the user` : '' 850 - } 851 - size="small" 852 - variant="solid" 853 - color="secondary_inverted" 854 - style={[a.mb_xs]} 855 - onPress={() => 856 - profile.viewer?.following 857 - ? void queueUnfollow() 858 - : void queueFollow() 859 - }> 860 - {!!profile.viewer?.following && ( 861 - <ButtonIcon icon={CheckIcon} /> 862 - )} 863 - <ButtonText> 864 - {profile.viewer?.following ? ( 865 - <Trans>Following</Trans> 866 - ) : ( 867 - <Trans>Follow</Trans> 852 + currentAccount && hasAlternateAccounts ? ( 853 + <EphemeralAccountSwitcher 854 + selectedDid={currentAccount.did} 855 + title={l`Follow as`} 856 + triggerBehavior="longPress" 857 + onSelectAccount={account => { 858 + void onSelectEphemeralAccount(account) 859 + }} 860 + renderTrigger={({triggerProps}) => ( 861 + <Button 862 + label={ 863 + profile.viewer?.following 864 + ? l`Following ${handle}` 865 + : l`Follow ${handle}` 866 + } 867 + accessibilityHint={ 868 + profile.viewer?.following 869 + ? l`Unfollows the user` 870 + : '' 871 + } 872 + onLongPress={triggerProps.onLongPress} 873 + size="small" 874 + variant="solid" 875 + color="secondary_inverted" 876 + style={[a.mb_xs]} 877 + onPress={() => 878 + profile.viewer?.following 879 + ? void queueUnfollow() 880 + : void queueFollow() 881 + }> 882 + {!!profile.viewer?.following && ( 883 + <ButtonIcon icon={CheckIcon} /> 884 + )} 885 + <ButtonText> 886 + {profile.viewer?.following ? ( 887 + <Trans>Following</Trans> 888 + ) : ( 889 + <Trans>Follow</Trans> 890 + )} 891 + </ButtonText> 892 + </Button> 893 + )} 894 + /> 895 + ) : ( 896 + <Button 897 + label={ 898 + profile.viewer?.following 899 + ? l`Following ${handle}` 900 + : l`Follow ${handle}` 901 + } 902 + accessibilityHint={ 903 + profile.viewer?.following ? l`Unfollows the user` : '' 904 + } 905 + size="small" 906 + variant="solid" 907 + color="secondary_inverted" 908 + style={[a.mb_xs]} 909 + onPress={() => 910 + profile.viewer?.following 911 + ? void queueUnfollow() 912 + : void queueFollow() 913 + }> 914 + {!!profile.viewer?.following && ( 915 + <ButtonIcon icon={CheckIcon} /> 868 916 )} 869 - </ButtonText> 870 - </Button> 917 + <ButtonText> 918 + {profile.viewer?.following ? ( 919 + <Trans>Following</Trans> 920 + ) : ( 921 + <Trans>Follow</Trans> 922 + )} 923 + </ButtonText> 924 + </Button> 925 + ) 871 926 )} 872 927 </View> 873 928 {record?.text?.trim() && (
+1
src/state/shell/composer/index.tsx
··· 49 49 | 'Other' 50 50 51 51 export interface ComposerOpts { 52 + activeAccountDid?: string 52 53 replyTo?: ComposerOptsPostRef 53 54 onPost?: (postUri: string | undefined) => void 54 55 onPostSuccess?: (data: OnPostSuccessData) => void
+8 -1
src/view/com/composer/Composer.tsx
··· 196 196 197 197 type Props = ComposerOpts 198 198 export const ComposePost = ({ 199 + activeAccountDid: initialActiveAccountDid, 199 200 replyTo, 200 201 onPost, 201 202 onPostSuccess, ··· 218 219 const queryClient = useQueryClient() 219 220 const currentDid = currentAccount!.did 220 221 221 - const [activeAccountDid, setActiveAccountDid] = useState<string>(currentDid) 222 + const [activeAccountDid, setActiveAccountDid] = useState<string>( 223 + initialActiveAccountDid ?? currentDid, 224 + ) 225 + 226 + useEffect(() => { 227 + setActiveAccountDid(initialActiveAccountDid ?? currentDid) 228 + }, [initialActiveAccountDid, currentDid]) 222 229 223 230 const {closeComposer} = useComposerControls() 224 231 const {requestSwitchToAccount} = useLoggedOutViewControls()
+1
src/view/shell/Composer.ios.tsx
··· 38 38 <TooltipSheetCompatProvider> 39 39 <ComposePost 40 40 cancelRef={ref} 41 + activeAccountDid={state?.activeAccountDid} 41 42 replyTo={state?.replyTo} 42 43 onPost={state?.onPost} 43 44 onPostSuccess={state?.onPostSuccess}
+1
src/view/shell/Composer.tsx
··· 47 47 aria-modal 48 48 accessibilityViewIsModal> 49 49 <ComposePost 50 + activeAccountDid={state.activeAccountDid} 50 51 replyTo={state.replyTo} 51 52 onPost={state.onPost} 52 53 onPostSuccess={state.onPostSuccess}
+1
src/view/shell/Composer.web.tsx
··· 74 74 ]}> 75 75 <ComposePost 76 76 cancelRef={ref} 77 + activeAccountDid={state.activeAccountDid} 77 78 replyTo={state.replyTo} 78 79 quote={state.quote} 79 80 onPost={state.onPost}