diff --git a/src/components/Button.tsx b/src/components/Button.tsx index bd4ebb2eb..7d8439500 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -245,6 +245,9 @@ export const Button = forwardRef( [clearLongPressTimer, onLongPressOuter], ) const onPointerUp = useCallback(() => { + if (longPressTriggeredRef.current) { + return + } clearLongPressTimer() }, [clearLongPressTimer]) const onPointerLeave = useCallback(() => { diff --git a/src/components/EphemeralAccountSwitcher.tsx b/src/components/EphemeralAccountSwitcher.tsx index dfaad887a..6f07539a4 100644 --- a/src/components/EphemeralAccountSwitcher.tsx +++ b/src/components/EphemeralAccountSwitcher.tsx @@ -1,4 +1,12 @@ -import {useMemo} from 'react' +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import {View} from 'react-native' import {type AppBskyActorDefs} from '@atproto/api' import {useLingui} from '@lingui/react/macro' @@ -10,14 +18,14 @@ import {useDialogControl} from '#/components/Dialog' import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount' import * as Menu from '#/components/Menu' import * as Prompt from '#/components/Prompt' -import {IS_WEB_TOUCH_DEVICE} from '#/env' +import {IS_NATIVE, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' type AccountListItem = { account: SessionAccount profile?: AppBskyActorDefs.ProfileViewDetailed } -type SwitcherTriggerProps = { +export type SwitcherTriggerProps = { ref: null onPress: (() => void) | undefined onLongPress?: (() => void) | undefined @@ -29,102 +37,436 @@ type SwitcherTriggerProps = { accessibilityRole: 'button' } +type SwitcherRequest = { + title: string + onSelectAccount: (account: SessionAccount) => void + accounts?: AccountListItem[] + resolveAccounts?: () => Promise +} + +type EphemeralAccountSwitcherContextValue = { + hasAlternateAccounts: boolean + currentProfile?: AppBskyActorDefs.ProfileViewDetailed + switcherAccounts: AccountListItem[] + signOutPromptControl: ReturnType + getLongPressProps: (request: SwitcherRequest) => Pick< + SwitcherTriggerProps, + 'onLongPress' | 'accessibilityLabel' | 'accessibilityRole' + > +} + +const EphemeralAccountSwitcherContext = + createContext(null) + +const noopTriggerProps: SwitcherTriggerProps = { + ref: null, + onPress: undefined, + onLongPress: undefined, + onFocus: () => {}, + onBlur: () => {}, + onPressIn: () => {}, + onPressOut: () => {}, + accessibilityLabel: '', + accessibilityRole: 'button', +} + +function useEphemeralAccountSwitcherData(selectedDid: string) { + const {accounts} = useSession() + const {data: currentProfile} = useProfileQuery({did: selectedDid}) + const {data} = useProfilesQuery({ + handles: accounts.map(acc => acc.did), + }) + const profiles = data?.profiles + + const switcherAccounts = useMemo( + () => + accounts + .filter(account => account.did !== selectedDid) + .map(account => ({ + account, + profile: profiles?.find(p => p.did === account.did), + })), + [accounts, profiles, selectedDid], + ) + + return { + switcherAccounts, + hasAlternateAccounts: switcherAccounts.length > 0, + currentProfile, + } +} + +export function EphemeralAccountSwitcherScope({ + selectedDid, + children, +}: { + selectedDid: string + children: React.ReactNode +}) { + const {t: l} = useLingui() + const {switcherAccounts, hasAlternateAccounts, currentProfile} = + useEphemeralAccountSwitcherData(selectedDid) + const menuControl = Menu.useMenuControl() + const signOutPromptControl = Prompt.usePromptControl() + const dismissGuardRef = useRef(false) + const [activeRequest, setActiveRequest] = useState( + null, + ) + const [resolvedAccounts, setResolvedAccounts] = useState< + AccountListItem[] | null + >(null) + const [isResolvingAccounts, setIsResolvingAccounts] = useState(false) + + const openMenuControl = useCallback(() => { + if (IS_WEB && !IS_WEB_TOUCH_DEVICE) { + dismissGuardRef.current = true + menuControl.open() + + const releaseGuard = () => { + requestAnimationFrame(() => { + dismissGuardRef.current = false + }) + } + window.addEventListener('pointerup', releaseGuard, {once: true}) + window.addEventListener('pointercancel', releaseGuard, {once: true}) + return + } + menuControl.open() + }, [menuControl]) + + const openSwitcher = useCallback( + (request: SwitcherRequest) => { + setActiveRequest(request) + setResolvedAccounts(null) + + if (request.resolveAccounts) { + setIsResolvingAccounts(true) + openMenuControl() + void request.resolveAccounts().then(accounts => { + setResolvedAccounts(accounts) + setIsResolvingAccounts(false) + if (accounts.length === 0) { + menuControl.close() + } + }).catch(() => { + setIsResolvingAccounts(false) + menuControl.close() + }) + return + } + + const accounts = request.accounts ?? switcherAccounts + if (accounts.length === 0) return + setResolvedAccounts(accounts) + openMenuControl() + }, + [switcherAccounts, menuControl, openMenuControl], + ) + + const handleSelectAccount = useCallback( + (account: SessionAccount) => { + activeRequest?.onSelectAccount(account) + }, + [activeRequest], + ) + + const getLongPressProps = useCallback( + (request: SwitcherRequest) => { + const accounts = request.accounts ?? switcherAccounts + if (accounts.length === 0 && !request.resolveAccounts) { + return { + onLongPress: undefined, + accessibilityLabel: l`Switch accounts`, + accessibilityRole: 'button' as const, + } + } + + return { + onLongPress: () => openSwitcher(request), + accessibilityLabel: l`Switch accounts`, + accessibilityRole: 'button' as const, + } + }, + [l, openSwitcher, switcherAccounts], + ) + + const menuAccounts = + resolvedAccounts ?? activeRequest?.accounts ?? switcherAccounts + + const contextValue = useMemo( + () => ({ + hasAlternateAccounts, + currentProfile, + switcherAccounts, + signOutPromptControl, + getLongPressProps, + }), + [ + currentProfile, + getLongPressProps, + hasAlternateAccounts, + signOutPromptControl, + switcherAccounts, + ], + ) + + return ( + + {children} + {hasAlternateAccounts && (IS_NATIVE || (IS_WEB && !IS_WEB_TOUCH_DEVICE)) ? ( + + + + ) : null} + + ) +} + +export function useEphemeralAccountSwitcher() { + const context = useContext(EphemeralAccountSwitcherContext) + if (!context) { + throw new Error( + 'useEphemeralAccountSwitcher must be used within EphemeralAccountSwitcherScope', + ) + } + return context +} + +export function EphemeralAccountSwitcherMenu({ + title, + onSelectAccount, + accounts: accountsOverride, + resolveAccounts, + renderTrigger, +}: { + title: string + onSelectAccount: (account: SessionAccount) => void + accounts?: AccountListItem[] + resolveAccounts?: () => Promise + renderTrigger: (args: { + currentProfile?: AppBskyActorDefs.ProfileViewDetailed + triggerProps: SwitcherTriggerProps + }) => React.ReactNode +}) { + const {t: l} = useLingui() + const { + hasAlternateAccounts, + currentProfile, + switcherAccounts, + signOutPromptControl, + } = useEphemeralAccountSwitcher() + const menuAccounts = accountsOverride ?? switcherAccounts + const hasMenuAccounts = resolveAccounts + ? hasAlternateAccounts + : menuAccounts.length > 0 + const menuControl = Menu.useMenuControl() + const dismissGuardRef = useRef(false) + const [resolvedAccounts, setResolvedAccounts] = useState< + AccountListItem[] | null + >(null) + const [isResolvingAccounts, setIsResolvingAccounts] = useState(false) + + const displayedAccounts = resolveAccounts + ? (resolvedAccounts ?? []) + : menuAccounts + + useEffect(() => { + if (!menuControl.isOpen) { + setResolvedAccounts(null) + setIsResolvingAccounts(false) + return + } + + if (!resolveAccounts || isResolvingAccounts || resolvedAccounts !== null) { + return + } + + setIsResolvingAccounts(true) + void resolveAccounts() + .then(accounts => { + setResolvedAccounts(accounts) + setIsResolvingAccounts(false) + if (accounts.length === 0) { + menuControl.close() + } + }) + .catch(() => { + setIsResolvingAccounts(false) + menuControl.close() + }) + }, [ + isResolvingAccounts, + menuControl, + menuControl.isOpen, + resolveAccounts, + resolvedAccounts, + ]) + + const openMenu = useCallback(() => { + if (menuControl.isOpen) { + return + } + dismissGuardRef.current = true + menuControl.open() + + const releaseGuard = () => { + requestAnimationFrame(() => { + dismissGuardRef.current = false + }) + } + window.addEventListener('pointerup', releaseGuard, {once: true}) + window.addEventListener('pointercancel', releaseGuard, {once: true}) + }, [menuControl]) + + if (!hasAlternateAccounts || !hasMenuAccounts) { + return renderTrigger({ + currentProfile, + triggerProps: { + ...noopTriggerProps, + accessibilityLabel: l`Switch accounts`, + }, + }) + } + + return ( + + + {({props: menuTriggerProps}) => ( + + {renderTrigger({ + currentProfile, + triggerProps: { + ref: null, + onPress: undefined, + onLongPress: openMenu, + onFocus: () => {}, + onBlur: () => {}, + onPressIn: () => {}, + onPressOut: () => {}, + accessibilityLabel: l`Switch accounts`, + accessibilityRole: 'button', + }, + })} + + )} + + + + ) +} + export function EphemeralAccountSwitcher({ selectedDid, title, onSelectAccount, triggerBehavior = 'press', + accounts: accountsOverride, + resolveAccounts, renderTrigger, }: { selectedDid: string title: string onSelectAccount: (account: SessionAccount) => void triggerBehavior?: 'press' | 'longPress' + accounts?: AccountListItem[] + resolveAccounts?: () => Promise renderTrigger: (args: { currentProfile?: AppBskyActorDefs.ProfileViewDetailed triggerProps: SwitcherTriggerProps }) => React.ReactNode }) { const {t: l} = useLingui() - const {accounts} = useSession() - const {data: currentProfile} = useProfileQuery({did: selectedDid}) - const {data} = useProfilesQuery({ - handles: accounts.map(acc => acc.did), - }) + const {switcherAccounts, hasAlternateAccounts, currentProfile} = + useEphemeralAccountSwitcherData(selectedDid) + const menuAccounts = accountsOverride ?? switcherAccounts const control = useDialogControl() const menuControl = Menu.useMenuControl() const signOutPromptControl = Prompt.usePromptControl() - const profiles = data?.profiles + const [resolvedAccounts, setResolvedAccounts] = useState< + AccountListItem[] | null + >(null) + const [isResolvingAccounts, setIsResolvingAccounts] = useState(false) - const switcherAccounts = useMemo( - () => - accounts - .filter(account => account.did !== selectedDid) - .map(account => ({ - account, - profile: profiles?.find(p => p.did === account.did), - })), - [accounts, profiles, selectedDid], - ) - const hasSwitcherAccounts = switcherAccounts.length > 0 + const dialogAccounts = resolvedAccounts ?? menuAccounts - if (!hasSwitcherAccounts) { + const openDialog = useCallback(() => { + if (resolveAccounts) { + setResolvedAccounts(null) + setIsResolvingAccounts(true) + control.open() + void resolveAccounts().then(accounts => { + setResolvedAccounts(accounts) + setIsResolvingAccounts(false) + if (accounts.length === 0) { + control.close() + } + }).catch(() => { + setIsResolvingAccounts(false) + control.close() + }) + return + } + control.open() + }, [control, resolveAccounts]) + + if (!hasAlternateAccounts || (!resolveAccounts && menuAccounts.length === 0)) { return renderTrigger({ currentProfile, triggerProps: { - ref: null, - onPress: undefined, - onLongPress: undefined, - onFocus: () => {}, - onBlur: () => {}, - onPressIn: () => {}, - onPressOut: () => {}, + ...noopTriggerProps, 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', - }, - })} - - )} - - - + + {IS_NATIVE ? ( + + ) : ( + + )} + ) } if (IS_WEB_TOUCH_DEVICE) { const openProps = triggerBehavior === 'longPress' - ? {onPress: undefined, onLongPress: control.open} - : {onPress: control.open, onLongPress: undefined} + ? {onPress: undefined, onLongPress: openDialog} + : {onPress: openDialog, onLongPress: undefined} return ( <> @@ -143,7 +485,8 @@ export function EphemeralAccountSwitcher({ })} item.account)} + accounts={dialogAccounts.map(item => item.account)} + isLoading={isResolvingAccounts} pendingDid={null} selectedDid={selectedDid} title={title} @@ -165,7 +508,7 @@ export function EphemeralAccountSwitcher({ } ) } + +function EphemeralAccountSwitcherNativeTrigger({ + request, + renderTrigger, +}: { + request: SwitcherRequest + renderTrigger: (args: { + currentProfile?: AppBskyActorDefs.ProfileViewDetailed + triggerProps: SwitcherTriggerProps + }) => React.ReactNode +}) { + const {currentProfile, getLongPressProps} = useEphemeralAccountSwitcher() + + return ( + + {renderTrigger({ + currentProfile, + triggerProps: { + ...noopTriggerProps, + ...getLongPressProps(request), + }, + })} + + ) +} diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index e83d71b8b..84d177f7e 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -43,8 +43,14 @@ export {useMenuContext} export function Root({ children, control, + modal: _modal = true, + disableBackdrop: _disableBackdrop = false, + dismissGuardRef: _dismissGuardRef, }: React.PropsWithChildren<{ control?: Dialog.DialogControlProps + modal?: boolean + disableBackdrop?: boolean + dismissGuardRef?: React.MutableRefObject }>) { const defaultControl = Dialog.useDialogControl() const context = useMemo( diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index bbb4fe471..9b92a9069 100644 --- a/src/components/Menu/index.web.tsx +++ b/src/components/Menu/index.web.tsx @@ -38,28 +38,32 @@ export {useMenuContext} export function useMenuControl(): Dialog.DialogControlProps { const id = useId() const [isOpen, setIsOpen] = useState(false) + const open = useCallback(() => setIsOpen(true), []) + const close = useCallback(() => setIsOpen(false), []) return useMemo( () => ({ id, ref: {current: null}, isOpen, - open() { - setIsOpen(true) - }, - close() { - setIsOpen(false) - }, + open, + close, }), - [id, isOpen, setIsOpen], + [id, isOpen, open, close], ) } export function Root({ children, control, + modal = true, + disableBackdrop = false, + dismissGuardRef, }: React.PropsWithChildren<{ control?: Dialog.DialogControlProps + modal?: boolean + disableBackdrop?: boolean + dismissGuardRef?: React.MutableRefObject }>) { const {_} = useLingui() const defaultControl = useMenuControl() @@ -71,18 +75,24 @@ export function Root({ ) const onOpenChange = useCallback( (open: boolean) => { - if (context.control.isOpen && !open) { - context.control.close() - } else if (!context.control.isOpen && open) { + if (!open && dismissGuardRef?.current) { + return + } + if (open === context.control.isOpen) { + return + } + if (open) { context.control.open() + } else { + context.control.close() } }, - [context.control], + [context.control, dismissGuardRef], ) return ( - {context.control.isOpen && ( + {modal && !disableBackdrop && context.control.isOpen && ( )} {children} diff --git a/src/components/PostControls/PostControlButton.tsx b/src/components/PostControls/PostControlButton.tsx index c5a8e68d3..b5596f8a4 100644 --- a/src/components/PostControls/PostControlButton.tsx +++ b/src/components/PostControls/PostControlButton.tsx @@ -63,12 +63,18 @@ export function PostControlButton({ ) const handlePress = useMemo(() => { - if (!onPress) return - return (evt: GestureResponderEvent) => { - playHaptic('Light') - onPress(evt) + if (onPress) { + return (evt: GestureResponderEvent) => { + playHaptic('Light') + onPress(evt) + } + } + // RN Web needs onPress defined for touch tracking when only long-pressing. + if (onLongPress) { + return () => {} } - }, [onPress, playHaptic]) + return undefined + }, [onPress, onLongPress, playHaptic]) const handleLongPress = useMemo(() => { if (!onLongPress) return diff --git a/src/components/PostControls/alternateAccountsReplyEligibility.ts b/src/components/PostControls/alternateAccountsReplyEligibility.ts new file mode 100644 index 000000000..a7b522be0 --- /dev/null +++ b/src/components/PostControls/alternateAccountsReplyEligibility.ts @@ -0,0 +1,58 @@ +import {type AppBskyActorDefs} from '@atproto/api' +import {type QueryClient} from '@tanstack/react-query' + +import {STALE} from '#/state/queries' +import {createQueryKey} from '#/state/queries/util' +import {canAttemptSessionResume} from '#/state/session/util' +import {type SessionAccount, type SessionApiContext} from '#/state/session' + +const queryKeyRoot = 'alternateAccountsReplyEligibility' + +export type ReplyableAccountListItem = { + account: SessionAccount + profile?: AppBskyActorDefs.ProfileViewDetailed +} + +export async function fetchReplyableSwitcherAccounts({ + queryClient, + postUri, + switcherAccounts, + createEphemeralAgent, +}: { + queryClient: QueryClient + postUri: string + switcherAccounts: ReplyableAccountListItem[] + createEphemeralAgent: SessionApiContext['createEphemeralAgent'] +}): Promise { + const alternateAccounts = switcherAccounts.map(item => item.account) + const accountDids = alternateAccounts.map(account => account.did) + + const replyableDids = await queryClient.fetchQuery({ + queryKey: createQueryKey(queryKeyRoot, {postUri, accountDids}), + staleTime: STALE.MINUTES.FIVE, + queryFn: async () => { + const results = new Set() + + for (const account of alternateAccounts) { + if (!canAttemptSessionResume(account)) { + continue + } + + try { + const agent = await createEphemeralAgent(account) + const res = await agent.getPosts({uris: [postUri]}) + const target = res.data.posts[0] + if (!target?.viewer?.replyDisabled) { + results.add(account.did) + } + } catch { + // Skip accounts we can't verify. + } + } + + return results + }, + }) + + return switcherAccounts.filter(item => replyableDids.has(item.account.did)) +} diff --git a/src/components/PostControls/index.tsx b/src/components/PostControls/index.tsx index 82b5f098a..d2e2e6dd6 100644 --- a/src/components/PostControls/index.tsx +++ b/src/components/PostControls/index.tsx @@ -1,4 +1,4 @@ -import {memo, useMemo, useState} from 'react' +import {memo, useCallback, useMemo, useState} from 'react' import {type StyleProp, View, type ViewStyle} from 'react-native' import { type AppBskyFeedDefs, @@ -8,6 +8,7 @@ import { } from '@atproto/api' import {plural} from '@lingui/core/macro' import {useLingui} from '@lingui/react/macro' +import {useQueryClient} from '@tanstack/react-query' import {CountWheel} from '#/lib/custom-animations/CountWheel' import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' @@ -29,22 +30,32 @@ import { usePostLikeMutationQueue, usePostRepostMutationQueue, } from '#/state/queries/post' -import {useRequireAuth, useSession} from '#/state/session' +import {useRequireAuth, useSession, useSessionApi} from '#/state/session' +import { + threadgateRecordToAllowUISetting, + threadgateViewToAllowUISetting, +} from '#/state/queries/threadgate/util' import { ProgressGuideAction, useProgressGuideControls, } from '#/state/shell/progress-guide' import * as userActionHistory from '#/state/userActionHistory' import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher' +import { + EphemeralAccountSwitcherMenu, + EphemeralAccountSwitcherScope, + useEphemeralAccountSwitcher, +} from '#/components/EphemeralAccountSwitcher' 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 {useAnalytics} from '#/analytics' +import {IS_NATIVE, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env' import {useAutoLikeOnRepost} from '../../state/preferences/auto-like-on-repost.tsx' import {useRunWithEphemeralAgent} from '../hooks/useRunWithEphemeralAgent' import {BookmarkButton} from './BookmarkButton' +import {fetchReplyableSwitcherAccounts} from './alternateAccountsReplyEligibility' import {MetricCountLabel} from './MetricCountLabel' import { PostControlButton, @@ -55,7 +66,7 @@ import {PostMenuButton} from './PostMenu' import {RepostButton} from './RepostButton' import {ShareMenuButton} from './ShareMenu' -let PostControls = ({ +function PostControlsInner({ big, post, record, @@ -87,13 +98,15 @@ let PostControls = ({ viaRepost?: {uri: string; cid: string} variant?: 'compact' | 'normal' | 'large' forceGoogleTranslate?: boolean -}): React.ReactNode => { +}): React.ReactNode { const ax = useAnalytics() const t = useTheme() const {t: l} = useLingui() const {openComposer} = useOpenComposer() const {feedDescriptor} = useFeedFeedbackContext() - const {accounts, currentAccount} = useSession() + const {accounts} = useSession() + const {createEphemeralAgent} = useSessionApi() + const queryClient = useQueryClient() const getPost = useGetPost() const runWithEphemeralAgent = useRunWithEphemeralAgent() const [queueLike, queueUnlike] = usePostLikeMutationQueue( @@ -117,6 +130,12 @@ let PostControls = ({ post.author.viewer?.blockingByList, ) const replyDisabled = post.viewer?.replyDisabled + const isReplyGatedPost = useMemo(() => { + const settings = threadgateRecord + ? threadgateRecordToAllowUISetting(threadgateRecord) + : threadgateViewToAllowUISetting(post.threadgate) + return !(settings.length === 1 && settings[0].type === 'everybody') + }, [threadgateRecord, post.threadgate]) const {gtPhone} = useBreakpoints() const likesMetricsDisplay = useLikesMetricsDisplay() const repostsMetricsDisplay = useRepostsMetricsDisplay() @@ -277,9 +296,39 @@ let PostControls = ({ big, gtPhone, }) - const hasAlternateAccounts = accounts.some( - account => account.did !== currentAccount?.did, - ) + const {getLongPressProps, hasAlternateAccounts, switcherAccounts} = + useEphemeralAccountSwitcher() + + const resolveReplyableAccounts = useCallback(async () => { + const replyableAccounts = await fetchReplyableSwitcherAccounts({ + queryClient, + postUri: post.uri, + switcherAccounts, + createEphemeralAgent, + }) + if (replyableAccounts.length === 0) { + Toast.show(l`No other accounts can reply to this post`, { + type: 'warning', + }) + } + return replyableAccounts + }, [ + createEphemeralAgent, + l, + post.uri, + queryClient, + switcherAccounts, + ]) + + const replyLongPress = hasAlternateAccounts + ? getLongPressProps({ + title: l`Reply as`, + resolveAccounts: isReplyGatedPost ? resolveReplyableAccounts : undefined, + onSelectAccount: account => { + onReplyAsAccount(account.did) + }, + }).onLongPress + : undefined const onSelectLikeAccount = async (account: (typeof accounts)[number]) => { try { @@ -372,6 +421,100 @@ let PostControls = ({ } } + const repostLongPress = + IS_NATIVE && hasAlternateAccounts + ? getLongPressProps({ + title: l`Repost as`, + onSelectAccount: account => { + void onSelectRepostAccount(account) + }, + }).onLongPress + : undefined + const likeLongPress = + IS_NATIVE && hasAlternateAccounts + ? getLongPressProps({ + title: l`Like as`, + onSelectAccount: account => { + void onSelectLikeAccount(account) + }, + }).onLongPress + : undefined + const bookmarkLongPress = + IS_NATIVE && hasAlternateAccounts + ? getLongPressProps({ + title: l`Save as`, + onSelectAccount: account => { + void onSelectBookmarkAccount(account) + }, + }).onLongPress + : undefined + const useWebReplySwitcherMenu = IS_WEB && hasAlternateAccounts + const useWebSwitcherMenu = + !IS_NATIVE && !IS_WEB_TOUCH_DEVICE && hasAlternateAccounts + + const renderReplyButton = (onLongPress?: () => void) => ( + + requireAuth(() => { + ax.metric('post:clickReply', { + uri: post.uri, + authorDid: post.author.did, + logContext, + feedDescriptor, + }) + onPressReply() + }) + : undefined + } + onLongPress={onLongPress ?? replyLongPress} + 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}> + + + + ) + + const renderRepostButton = (onLongPress?: () => void) => ( + 0 + ? repostsMetricsDisplay + : quotesMetricsDisplay + } + onRepost={() => void onRepost()} + onQuote={onQuote} + onLongPress={onLongPress ?? repostLongPress} + big={big} + embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} + /> + ) + const renderLikeButton = (onLongPress?: () => void) => ( requireAuth(() => onPressToggleLike())} - onLongPress={onLongPress} + onLongPress={onLongPress ?? likeLongPress} label={ post.viewer?.like ? l({ @@ -453,150 +596,42 @@ let PostControls = ({ {marginLeft: big ? -2 : -6}, replyDisabled ? {opacity: 0.6} : undefined, ]}> - {currentAccount && hasAlternateAccounts && !replyDisabled ? ( - { onReplyAsAccount(account.did) }} - renderTrigger={({triggerProps}) => ( - - requireAuth(() => { - ax.metric('post:clickReply', { - uri: post.uri, - authorDid: post.author.did, - logContext, - feedDescriptor, - }) - onPressReply() - }) - } - onLongPress={triggerProps.onLongPress} - 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}> - - - - )} + renderTrigger={({triggerProps}) => + renderReplyButton(triggerProps.onLongPress) + } /> ) : ( - - 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}> - - - + renderReplyButton(replyLongPress) )} - {currentAccount && hasAlternateAccounts ? ( - { void onSelectRepostAccount(account) }} - renderTrigger={({triggerProps}) => ( - 0 - ? repostsMetricsDisplay - : quotesMetricsDisplay - } - onRepost={() => void onRepost()} - onQuote={onQuote} - onLongPress={triggerProps.onLongPress} - big={big} - embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} - /> - )} - /> - ) : ( - 0 - ? repostsMetricsDisplay - : quotesMetricsDisplay + renderTrigger={({triggerProps}) => + renderRepostButton(triggerProps.onLongPress) } - onRepost={() => void onRepost()} - onQuote={onQuote} - big={big} - embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} /> + ) : ( + renderRepostButton() )} - {currentAccount && hasAlternateAccounts ? ( - { void onSelectLikeAccount(account) }} @@ -613,11 +648,9 @@ let PostControls = ({ - {currentAccount && hasAlternateAccounts ? ( - { void onSelectBookmarkAccount(account) }} @@ -638,6 +671,7 @@ let PostControls = ({ post={post} big={big} logContext={logContext} + onLongPress={bookmarkLongPress} hitSlop={{ right: secondaryControlSpacingStyles.gap / 2, }} @@ -680,7 +714,18 @@ let PostControls = ({ ) } -PostControls = memo(PostControls) + +const PostControls = memo(function PostControls( + props: Parameters[0], +) { + const {currentAccount} = useSession() + + return ( + + + + ) +}) export {PostControls} export function PostControlsSkeleton({ diff --git a/src/components/dialogs/SwitchAccount.tsx b/src/components/dialogs/SwitchAccount.tsx index 28e565603..fac517b2c 100644 --- a/src/components/dialogs/SwitchAccount.tsx +++ b/src/components/dialogs/SwitchAccount.tsx @@ -9,12 +9,14 @@ import {type SessionAccount, useSession} from '#/state/session' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {atoms as a} from '#/alf' import * as Dialog from '#/components/Dialog' +import {Loader} from '#/components/Loader' import {AccountList} from '../AccountList' import {Text} from '../Typography' export function SwitchAccountDialog({ control, accounts, + isLoading, title, pendingDid: pendingDidProp, selectedDid, @@ -25,6 +27,7 @@ export function SwitchAccountDialog({ }: { control: Dialog.DialogControlProps accounts?: SessionAccount[] + isLoading?: boolean title?: string pendingDid?: string | null selectedDid?: string | null @@ -78,15 +81,21 @@ export function SwitchAccountDialog({ {title ?? Switch account} - + {isLoading ? ( + + + + ) : ( + + )} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index 029a10c8e..b83b33ec2 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -326,8 +326,13 @@ export function Provider({children}: PropsWithChildren<{}>) { >( async storedAccount => { if (storedAccount.isOauthSession) { - const {agent} = await oauthResumeSession(storedAccount) - return agent as unknown as AtpAgent + try { + const {agent} = await oauthResumeSession(storedAccount, false) + return agent as unknown as AtpAgent + } catch { + const {agent} = await oauthResumeSession(storedAccount, 'auto') + return agent as unknown as AtpAgent + } } const {agent} = await createAgentAndResume( storedAccount, diff --git a/src/state/session/oauth-agent.ts b/src/state/session/oauth-agent.ts index c66415e93..46e0ead5a 100644 --- a/src/state/session/oauth-agent.ts +++ b/src/state/session/oauth-agent.ts @@ -25,11 +25,14 @@ export async function oauthCreateAgent(session: OAuthSession) { const OAUTH_RESTORE_TIMEOUT_MS = 10_000 -export async function oauthResumeSession(account: SessionAccount) { +export async function oauthResumeSession( + account: SessionAccount, + refresh: boolean | 'auto' = 'auto', +) { let session: OAuthSession try { session = await Promise.race([ - restoreOAuthSession(account.did), + restoreOAuthSession(account.did, refresh), new Promise((_, reject) => setTimeout( () => reject(new Error('OAuth session restore timed out')), diff --git a/src/state/session/oauth-client-adapter.ts b/src/state/session/oauth-client-adapter.ts index 2b14b54af..00c4b5cc0 100644 --- a/src/state/session/oauth-client-adapter.ts +++ b/src/state/session/oauth-client-adapter.ts @@ -2,6 +2,15 @@ import {type OAuthSession} from '@atproto/oauth-client-browser' import {getWebOAuthClient} from './oauth-web-client' -export function restoreOAuthSession(did: string): Promise { - return getWebOAuthClient().restore(did) +let restoreChain: Promise = Promise.resolve() + +export function restoreOAuthSession( + did: string, + refresh: boolean | 'auto' = 'auto', +): Promise { + const result = restoreChain.then(() => + getWebOAuthClient().restore(did, refresh), + ) + restoreChain = result.catch(() => {}) + return result } diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 9a0b206e4..039ebbccb 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -40,6 +40,7 @@ import { } from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {type DialogControlProps} from '#/components/Dialog' +import {Loader} from '#/components/Loader' import {ArrowBoxLeft_Stroke2_Corner0_Rounded as LeaveIcon} from '#/components/icons/ArrowBoxLeft' import { Bell_Filled_Corner0_Rounded as BellFilledIcon, @@ -244,6 +245,7 @@ function ProfileCard({minimal}: {minimal: boolean}) { export function SwitchMenuItems({ accounts, + isLoading, signOutPromptControl, showExtraButtons, showAddAccount, @@ -256,6 +258,7 @@ export function SwitchMenuItems({ profile?: AppBskyActorDefs.ProfileViewDetailed }[] | undefined + isLoading?: boolean signOutPromptControl: DialogControlProps showExtraButtons?: boolean showAddAccount?: boolean @@ -281,7 +284,16 @@ export function SwitchMenuItems({ return ( - {sortedAccounts.length > 0 && ( + {isLoading ? ( + + + {title ?? Switch account} + + + + + + ) : sortedAccounts.length > 0 ? ( <> @@ -298,7 +310,7 @@ export function SwitchMenuItems({ {hasFooterItems ? : null} - )} + ) : null} {showExtraButtons ? : undefined} {showAddAccount ? (