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 ? (