Merge branch 'main' into starter-packs

This commit is contained in:
Hailey
2024-06-04 11:19:37 -07:00
51 changed files with 1817 additions and 577 deletions
+138 -183
View File
@@ -1,19 +1,22 @@
import React from 'react'
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, plural, Trans} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {logger} from '#/logger'
import {useAccountSwitcher} from '#/lib/hooks/useAccountSwitcher'
import {isWeb} from '#/platform/detection'
import {isSessionDeactivated, useAgent, useSessionApi} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {type SessionAccount, useSession, useSessionApi} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {ScrollView} from '#/view/com/util/Views'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Loader} from '#/components/Loader'
import {P, Text} from '#/components/Typography'
import {atoms as a, useTheme} from '#/alf'
import {AccountList} from '#/components/AccountList'
import {Button, ButtonText} from '#/components/Button'
import {Divider} from '#/components/Divider'
import {Text} from '#/components/Typography'
const COL_WIDTH = 400
@@ -21,199 +24,151 @@ export function Deactivated() {
const {_} = useLingui()
const t = useTheme()
const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const onboardingDispatch = useOnboardingDispatch()
const {currentAccount, accounts} = useSession()
const {onPressSwitchAccount, pendingDid} = useAccountSwitcher()
const {setShowLoggedOut} = useLoggedOutViewControls()
const hasOtherAccounts = accounts.length > 1
const setMinimalShellMode = useSetMinimalShellMode()
const {logout} = useSessionApi()
const agent = useAgent()
const [isProcessing, setProcessing] = React.useState(false)
const [estimatedTime, setEstimatedTime] = React.useState<string | undefined>(
undefined,
)
const [placeInQueue, setPlaceInQueue] = React.useState<number | undefined>(
undefined,
useFocusEffect(
React.useCallback(() => {
setMinimalShellMode(true)
}, [setMinimalShellMode]),
)
const checkStatus = React.useCallback(async () => {
setProcessing(true)
try {
const res = await agent.com.atproto.temp.checkSignupQueue()
if (res.data.activated) {
// ready to go, exchange the access token for a usable one and kick off onboarding
await agent.refreshSession()
if (!isSessionDeactivated(agent.session?.accessJwt)) {
onboardingDispatch({type: 'start'})
}
} else {
// not ready, update UI
setEstimatedTime(msToString(res.data.estimatedTimeMs))
if (typeof res.data.placeInQueue !== 'undefined') {
setPlaceInQueue(Math.max(res.data.placeInQueue, 1))
}
const onSelectAccount = React.useCallback(
(account: SessionAccount) => {
if (account.did !== currentAccount?.did) {
onPressSwitchAccount(account, 'SwitchAccount')
}
} catch (e: any) {
logger.error('Failed to check signup queue', {err: e.toString()})
} finally {
setProcessing(false)
}
}, [
setProcessing,
setEstimatedTime,
setPlaceInQueue,
onboardingDispatch,
agent,
])
React.useEffect(() => {
checkStatus()
const interval = setInterval(checkStatus, 60e3)
return () => clearInterval(interval)
}, [checkStatus])
const checkBtn = (
<Button
variant="solid"
color="primary"
size="large"
label={_(msg`Check my status`)}
onPress={checkStatus}
disabled={isProcessing}>
<ButtonText>
<Trans>Check my status</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
},
[currentAccount, onPressSwitchAccount],
)
const onPressAddAccount = React.useCallback(() => {
setShowLoggedOut(true)
}, [setShowLoggedOut])
const onPressLogout = React.useCallback(() => {
if (isWeb) {
// We're switching accounts, which remounts the entire app.
// On mobile, this gets us Home, but on the web we also need reset the URL.
// We can't change the URL via a navigate() call because the navigator
// itself is about to unmount, and it calls pushState() too late.
// So we change the URL ourselves. The navigator will pick it up on remount.
history.pushState(null, '', '/')
}
logout('Deactivated')
}, [logout])
return (
<View
aria-modal
role="dialog"
aria-role="dialog"
aria-label={_(msg`You're in line`)}
accessibilityLabel={_(msg`You're in line`)}
accessibilityHint=""
style={[a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}>
<View style={[a.h_full_vh, a.flex_1, t.atoms.bg]}>
<ScrollView
style={[a.h_full, a.w_full]}
contentContainerStyle={{borderWidth: 0}}>
<View
style={[a.flex_row, a.justify_center, gtMobile ? a.pt_4xl : a.px_xl]}>
<View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
<View
style={[a.w_full, a.justify_center, a.align_center, a.my_4xl]}>
<Logo width={120} />
</View>
<Text style={[a.text_4xl, a.font_bold, a.pb_sm]}>
<Trans>You're in line</Trans>
</Text>
<P style={[t.atoms.text_contrast_medium]}>
<Trans>
There's been a rush of new users to Bluesky! We'll activate your
account as soon as we can.
</Trans>
</P>
<View
style={[
a.rounded_sm,
a.px_2xl,
a.py_4xl,
a.mt_2xl,
t.atoms.bg_contrast_50,
]}>
{typeof placeInQueue === 'number' && (
<Text
style={[a.text_5xl, a.text_center, a.font_bold, a.mb_2xl]}>
{placeInQueue}
</Text>
)}
<P style={[a.text_center]}>
{typeof placeInQueue === 'number' ? (
<Trans>left to go.</Trans>
) : (
<Trans>You are in line.</Trans>
)}{' '}
{estimatedTime ? (
<Trans>
We estimate {estimatedTime} until your account is ready.
</Trans>
) : (
<Trans>
We will let you know when your account is ready.
</Trans>
)}
</P>
</View>
{isWeb && gtMobile && (
<View style={[a.w_full, a.flex_row, a.justify_between, a.pt_5xl]}>
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logout('Deactivated')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
{checkBtn}
</View>
)}
</View>
<View style={{height: 200}} />
</View>
</ScrollView>
{(!isWeb || !gtMobile) && (
<View
style={[
a.align_center,
gtMobile ? a.px_5xl : a.px_xl,
a.px_2xl,
{
paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom),
paddingTop: isWeb ? 64 : insets.top,
paddingBottom: isWeb ? 64 : insets.bottom,
},
]}>
<View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}>
{checkBtn}
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logout('Deactivated')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
<View style={[a.flex_row, a.justify_center]}>
<View style={[a.w_full, {maxWidth: COL_WIDTH}]}>
<View
style={[a.w_full, a.justify_center, a.align_center, a.pb_5xl]}>
<Logo width={40} />
</View>
<View style={[a.gap_xs, a.pb_3xl]}>
<Text style={[a.text_xl, a.font_bold, a.leading_snug]}>
<Trans>Welcome back!</Trans>
</Text>
<Text style={[a.text_sm, a.leading_snug]}>
<Trans>
You previously deactivated @{currentAccount?.handle}.
</Trans>
</Text>
<Text style={[a.text_sm, a.leading_snug, a.pb_md]}>
<Trans>
You can reactivate your account to continue logging in. Your
profile and posts will be visible to other users.
</Trans>
</Text>
<View style={[a.gap_sm]}>
<Button
label={_(msg`Reactivate your account`)}
size="medium"
variant="solid"
color="primary"
onPress={() => setShowLoggedOut(true)}>
<ButtonText>
<Trans>Yes, reactivate my account</Trans>
</ButtonText>
</Button>
<Button
label={_(msg`Cancel reactivation and log out`)}
size="medium"
variant="solid"
color="secondary"
onPress={onPressLogout}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
</View>
<View style={[a.pb_3xl]}>
<Divider />
</View>
{hasOtherAccounts ? (
<>
<Text
style={[
t.atoms.text_contrast_medium,
a.pb_md,
a.leading_snug,
]}>
<Trans>Or, log into one of your other accounts.</Trans>
</Text>
<AccountList
onSelectAccount={onSelectAccount}
onSelectOther={onPressAddAccount}
otherLabel={_(msg`Add account`)}
pendingDid={pendingDid}
/>
</>
) : (
<>
<Text
style={[
t.atoms.text_contrast_medium,
a.pb_md,
a.leading_snug,
]}>
<Trans>Or, continue with another account.</Trans>
</Text>
<Button
label={_(msg`Log in or sign up`)}
size="medium"
variant="solid"
color="secondary"
onPress={() => setShowLoggedOut(true)}>
<ButtonText>
<Trans>Log in or sign up</Trans>
</ButtonText>
</Button>
</>
)}
</View>
</View>
</View>
)}
</ScrollView>
</View>
)
}
function msToString(ms: number | undefined): string | undefined {
if (ms && ms > 0) {
const estimatedTimeMins = Math.ceil(ms / 60e3)
if (estimatedTimeMins > 59) {
const estimatedTimeHrs = Math.round(estimatedTimeMins / 60)
if (estimatedTimeHrs > 6) {
// dont even bother
return undefined
}
// hours
return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, {
one: 'hour',
other: 'hours',
})}`
}
// minutes
return `${estimatedTimeMins} ${plural(estimatedTimeMins, {
one: 'minute',
other: 'minutes',
})}`
}
return undefined
}
@@ -13,9 +13,13 @@ import {
} from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyEmbedRecord, RichText} from '@atproto/api'
import {AppBskyEmbedRecord, AppBskyRichtextFacet, RichText} from '@atproto/api'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {
convertBskyAppUrlIfNeeded,
isBskyPostUrl,
} from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
import {isConvoActive, useConvoActive} from '#/state/messages/convo'
@@ -289,6 +293,39 @@ export function MessagesList({
cid: post.cid,
},
}
// look for the embed uri in the facets, so we can remove it from the text
const postLinkFacet = rt.facets?.find(facet => {
return facet.features.find(feature => {
if (AppBskyRichtextFacet.isLink(feature)) {
if (isBskyPostUrl(feature.uri)) {
const url = convertBskyAppUrlIfNeeded(feature.uri)
const [_0, _1, _2, rkey] = url.split('/').filter(Boolean)
// this might have a handle instead of a DID
// so just compare the rkey - not particularly dangerous
return post.uri.endsWith(rkey)
}
}
return false
})
})
if (postLinkFacet) {
const isAtStart = postLinkFacet.index.byteStart === 0
const isAtEnd =
postLinkFacet.index.byteEnd === rt.unicodeText.graphemeLength
// remove the post link from the text
if (isAtStart || isAtEnd) {
rt.delete(
postLinkFacet.index.byteStart,
postLinkFacet.index.byteEnd,
)
}
rt = new RichText({text: rt.text.trim()}, {cleanNewlines: true})
}
}
} catch (error) {
logger.error('Failed to get post as quote for DM', {error})
+63 -14
View File
@@ -2,6 +2,7 @@ import React, {useCallback, useState} from 'react'
import {GestureResponderEvent, View} from 'react-native'
import {
AppBskyActorDefs,
AppBskyEmbedRecord,
ChatBskyConvoDefs,
moderateProfile,
ModerationOpts,
@@ -9,6 +10,11 @@ import {
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
postUriToRelativePath,
toBskyAppUrl,
toShortUrl,
} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
@@ -95,21 +101,64 @@ function ChatListItemReady({
const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount
let lastMessage = _(msg`No messages yet`)
let lastMessageSentAt: string | null = null
if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
if (convo.lastMessage.sender?.did === currentAccount?.did) {
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
} else {
lastMessage = convo.lastMessage.text
const {lastMessage, lastMessageSentAt} = React.useMemo(() => {
let lastMessage = _(msg`No messages yet`)
let lastMessageSentAt: string | null = null
if (ChatBskyConvoDefs.isMessageView(convo.lastMessage)) {
const isFromMe = convo.lastMessage.sender?.did === currentAccount?.did
if (convo.lastMessage.text) {
if (isFromMe) {
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
} else {
lastMessage = convo.lastMessage.text
}
} else if (convo.lastMessage.embed) {
const defaultEmbeddedContentMessage = _(
msg`(contains embedded content)`,
)
if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) {
const embed = convo.lastMessage.embed
if (AppBskyEmbedRecord.isViewRecord(embed.record)) {
const record = embed.record
const path = postUriToRelativePath(record.uri, {
handle: record.author.handle,
})
const href = path ? toBskyAppUrl(path) : undefined
const short = href
? toShortUrl(href)
: defaultEmbeddedContentMessage
if (isFromMe) {
lastMessage = _(msg`You: ${short}`)
} else {
lastMessage = short
}
}
} else {
if (isFromMe) {
lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`)
} else {
lastMessage = defaultEmbeddedContentMessage
}
}
}
lastMessageSentAt = convo.lastMessage.sentAt
}
lastMessageSentAt = convo.lastMessage.sentAt
}
if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
lastMessage = isDeletedAccount
? _(msg`Conversation deleted`)
: _(msg`Message deleted`)
}
if (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage)) {
lastMessage = isDeletedAccount
? _(msg`Conversation deleted`)
: _(msg`Message deleted`)
}
return {
lastMessage,
lastMessageSentAt,
}
}, [_, convo.lastMessage, currentAccount?.did, isDeletedAccount])
const [showActions, setShowActions] = useState(false)
@@ -0,0 +1,60 @@
import React from 'react'
import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {atoms as a, useTheme} from '#/alf'
import {DialogOuterProps} from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
export function DeactivateAccountDialog({
control,
}: {
control: DialogOuterProps['control']
}) {
const t = useTheme()
const {_} = useLingui()
return (
<Prompt.Outer control={control} testID="confirmModal">
<Prompt.TitleText>{_(msg`Deactivate account`)}</Prompt.TitleText>
<Prompt.DescriptionText>
<Trans>
Your profile, posts, feeds, and lists will no longer be visible to
other Bluesky users. You can reactivate your account at any time by
logging in.
</Trans>
</Prompt.DescriptionText>
<View style={[a.pb_xl]}>
<Divider />
<View style={[a.gap_sm, a.pt_lg, a.pb_xl]}>
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
<Trans>
There is no time limit for account deactivation, come back any
time.
</Trans>
</Text>
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
<Trans>
If you're trying to change your handle or email, do so before you
deactivate.
</Trans>
</Text>
</View>
<Divider />
</View>
<Prompt.Actions>
<Prompt.Action
cta={_(msg`Yes, deactivate`)}
onPress={() => {}}
color="negative"
/>
<Prompt.Cancel />
</Prompt.Actions>
</Prompt.Outer>
)
}
+219
View File
@@ -0,0 +1,219 @@
import React from 'react'
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {isSignupQueued, useAgent, useSessionApi} from '#/state/session'
import {useOnboardingDispatch} from '#/state/shell'
import {ScrollView} from '#/view/com/util/Views'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Loader} from '#/components/Loader'
import {P, Text} from '#/components/Typography'
const COL_WIDTH = 400
export function SignupQueued() {
const {_} = useLingui()
const t = useTheme()
const insets = useSafeAreaInsets()
const {gtMobile} = useBreakpoints()
const onboardingDispatch = useOnboardingDispatch()
const {logout} = useSessionApi()
const agent = useAgent()
const [isProcessing, setProcessing] = React.useState(false)
const [estimatedTime, setEstimatedTime] = React.useState<string | undefined>(
undefined,
)
const [placeInQueue, setPlaceInQueue] = React.useState<number | undefined>(
undefined,
)
const checkStatus = React.useCallback(async () => {
setProcessing(true)
try {
const res = await agent.com.atproto.temp.checkSignupQueue()
if (res.data.activated) {
// ready to go, exchange the access token for a usable one and kick off onboarding
await agent.refreshSession()
if (!isSignupQueued(agent.session?.accessJwt)) {
onboardingDispatch({type: 'start'})
}
} else {
// not ready, update UI
setEstimatedTime(msToString(res.data.estimatedTimeMs))
if (typeof res.data.placeInQueue !== 'undefined') {
setPlaceInQueue(Math.max(res.data.placeInQueue, 1))
}
}
} catch (e: any) {
logger.error('Failed to check signup queue', {err: e.toString()})
} finally {
setProcessing(false)
}
}, [
setProcessing,
setEstimatedTime,
setPlaceInQueue,
onboardingDispatch,
agent,
])
React.useEffect(() => {
checkStatus()
const interval = setInterval(checkStatus, 60e3)
return () => clearInterval(interval)
}, [checkStatus])
const checkBtn = (
<Button
variant="solid"
color="primary"
size="large"
label={_(msg`Check my status`)}
onPress={checkStatus}
disabled={isProcessing}>
<ButtonText>
<Trans>Check my status</Trans>
</ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />}
</Button>
)
return (
<View
aria-modal
role="dialog"
aria-role="dialog"
aria-label={_(msg`You're in line`)}
accessibilityLabel={_(msg`You're in line`)}
accessibilityHint=""
style={[a.absolute, a.inset_0, a.flex_1, t.atoms.bg]}>
<ScrollView
style={[a.h_full, a.w_full]}
contentContainerStyle={{borderWidth: 0}}>
<View
style={[a.flex_row, a.justify_center, gtMobile ? a.pt_4xl : a.px_xl]}>
<View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
<View
style={[a.w_full, a.justify_center, a.align_center, a.my_4xl]}>
<Logo width={120} />
</View>
<Text style={[a.text_4xl, a.font_bold, a.pb_sm]}>
<Trans>You're in line</Trans>
</Text>
<P style={[t.atoms.text_contrast_medium]}>
<Trans>
There's been a rush of new users to Bluesky! We'll activate your
account as soon as we can.
</Trans>
</P>
<View
style={[
a.rounded_sm,
a.px_2xl,
a.py_4xl,
a.mt_2xl,
t.atoms.bg_contrast_50,
]}>
{typeof placeInQueue === 'number' && (
<Text
style={[a.text_5xl, a.text_center, a.font_bold, a.mb_2xl]}>
{placeInQueue}
</Text>
)}
<P style={[a.text_center]}>
{typeof placeInQueue === 'number' ? (
<Trans>left to go.</Trans>
) : (
<Trans>You are in line.</Trans>
)}{' '}
{estimatedTime ? (
<Trans>
We estimate {estimatedTime} until your account is ready.
</Trans>
) : (
<Trans>
We will let you know when your account is ready.
</Trans>
)}
</P>
</View>
{isWeb && gtMobile && (
<View style={[a.w_full, a.flex_row, a.justify_between, a.pt_5xl]}>
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logout('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
{checkBtn}
</View>
)}
</View>
<View style={{height: 200}} />
</View>
</ScrollView>
{(!isWeb || !gtMobile) && (
<View
style={[
a.align_center,
gtMobile ? a.px_5xl : a.px_xl,
{
paddingBottom: Math.max(insets.bottom, a.pb_5xl.paddingBottom),
},
]}>
<View style={[a.w_full, a.gap_sm, {maxWidth: COL_WIDTH}]}>
{checkBtn}
<Button
variant="ghost"
size="large"
label={_(msg`Log out`)}
onPress={() => logout('SignupQueued')}>
<ButtonText style={[{color: t.palette.primary_500}]}>
<Trans>Log out</Trans>
</ButtonText>
</Button>
</View>
</View>
)}
</View>
)
}
function msToString(ms: number | undefined): string | undefined {
if (ms && ms > 0) {
const estimatedTimeMins = Math.ceil(ms / 60e3)
if (estimatedTimeMins > 59) {
const estimatedTimeHrs = Math.round(estimatedTimeMins / 60)
if (estimatedTimeHrs > 6) {
// dont even bother
return undefined
}
// hours
return `${estimatedTimeHrs} ${plural(estimatedTimeHrs, {
one: 'hour',
other: 'hours',
})}`
}
// minutes
return `${estimatedTimeMins} ${plural(estimatedTimeMins, {
one: 'minute',
other: 'minutes',
})}`
}
return undefined
}