- {typeof placeInQueue === 'number' ? (
- left to go.
- ) : (
- You are in line.
- )}{' '}
- {estimatedTime ? (
-
- We estimate {estimatedTime} until your account is ready.
-
- ) : (
-
- We will let you know when your account is ready.
-
- )}
-
-
-
- {isWeb && gtMobile && (
-
-
- {checkBtn}
-
- )}
-
-
-
-
-
-
- {(!isWeb || !gtMobile) && (
-
- {checkBtn}
-
+
+
+
+
+
+
+
+
+ Welcome back!
+
+
+
+ You previously deactivated @{currentAccount?.handle}.
+
+
+
+
+ You can reactivate your account to continue logging in. Your
+ profile and posts will be visible to other users.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {hasOtherAccounts ? (
+ <>
+
+ Or, log into one of your other accounts.
+
+
+ >
+ ) : (
+ <>
+
+ Or, continue with another account.
+
+
+ >
+ )}
+
- )}
+
)
}
-
-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
-}
diff --git a/src/screens/Messages/Conversation/MessagesList.tsx b/src/screens/Messages/Conversation/MessagesList.tsx
index de77997f1d..f72515ac62 100644
--- a/src/screens/Messages/Conversation/MessagesList.tsx
+++ b/src/screens/Messages/Conversation/MessagesList.tsx
@@ -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})
diff --git a/src/screens/Messages/List/ChatListItem.tsx b/src/screens/Messages/List/ChatListItem.tsx
index d5658249d7..9f8808366f 100644
--- a/src/screens/Messages/List/ChatListItem.tsx
+++ b/src/screens/Messages/List/ChatListItem.tsx
@@ -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)
diff --git a/src/screens/Settings/components/DeactivateAccountDialog.tsx b/src/screens/Settings/components/DeactivateAccountDialog.tsx
new file mode 100644
index 0000000000..4330ffcaa2
--- /dev/null
+++ b/src/screens/Settings/components/DeactivateAccountDialog.tsx
@@ -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 (
+
+ {_(msg`Deactivate account`)}
+
+
+ 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.
+
+
+
+
+
+
+
+
+ There is no time limit for account deactivation, come back any
+ time.
+
+
+
+
+ If you're trying to change your handle or email, do so before you
+ deactivate.
+
+
+
+
+
+
+
+ {}}
+ color="negative"
+ />
+
+
+
+ )
+}
diff --git a/src/screens/SignupQueued.tsx b/src/screens/SignupQueued.tsx
new file mode 100644
index 0000000000..4e4fedcfae
--- /dev/null
+++ b/src/screens/SignupQueued.tsx
@@ -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(
+ undefined,
+ )
+ const [placeInQueue, setPlaceInQueue] = React.useState(
+ 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 = (
+
+ )
+
+ return (
+
+
+
+
+
+
+
+
+
+ You're in line
+
+
+
+ There's been a rush of new users to Bluesky! We'll activate your
+ account as soon as we can.
+
+
+ {typeof placeInQueue === 'number' ? (
+ left to go.
+ ) : (
+ You are in line.
+ )}{' '}
+ {estimatedTime ? (
+
+ We estimate {estimatedTime} until your account is ready.
+
+ ) : (
+
+ We will let you know when your account is ready.
+
+ )}
+
+
+
+ {isWeb && gtMobile && (
+
+
+ {checkBtn}
+
+ )}
+
+
+
+
+
+
+ {(!isWeb || !gtMobile) && (
+
+
+ {checkBtn}
+
+
+
+ )}
+
+ )
+}
+
+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
+}
diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts
index 77a79b78e4..7d579d55de 100644
--- a/src/state/persisted/schema.ts
+++ b/src/state/persisted/schema.ts
@@ -17,7 +17,10 @@ const accountSchema = z.object({
emailAuthFactor: z.boolean().optional(),
refreshJwt: z.string().optional(), // optional because it can expire
accessJwt: z.string().optional(), // optional because it can expire
- deactivated: z.boolean().optional(),
+ signupQueued: z.boolean().optional(),
+ status: z
+ .enum(['active', 'takendown', 'suspended', 'deactivated'])
+ .optional(),
pdsUrl: z.string().optional(),
})
export type PersistedAccount = z.infer
@@ -65,6 +68,7 @@ export const schema = z.object({
spotify: z.enum(externalEmbedOptions).optional(),
appleMusic: z.enum(externalEmbedOptions).optional(),
soundcloud: z.enum(externalEmbedOptions).optional(),
+ flickr: z.enum(externalEmbedOptions).optional(),
})
.optional(),
mutedThreads: z.array(z.string()), // should move to server
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index 40be2ce8ee..d9f019af38 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -17,7 +17,7 @@
*/
import {useEffect, useRef} from 'react'
-import {AppBskyActorDefs, AppBskyFeedDefs} from '@atproto/api'
+import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
import {
InfiniteData,
QueryClient,
@@ -30,7 +30,11 @@ import {useMutedThreads} from '#/state/muted-threads'
import {useAgent} from '#/state/session'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..'
-import {embedViewRecordToPostView, getEmbeddedPost} from '../util'
+import {
+ didOrHandleUriMatches,
+ embedViewRecordToPostView,
+ getEmbeddedPost,
+} from '../util'
import {FeedPage} from './types'
import {useUnreadNotificationsApi} from './unread'
import {fetchPage} from './util'
@@ -142,6 +146,8 @@ export function* findAllPostsInQueryData(
queryClient: QueryClient,
uri: string,
): Generator {
+ const atUri = new AtUri(uri)
+
const queryDatas = queryClient.getQueriesData>({
queryKey: [RQKEY_ROOT],
})
@@ -149,14 +155,16 @@ export function* findAllPostsInQueryData(
if (!queryData?.pages) {
continue
}
+
for (const page of queryData?.pages) {
for (const item of page.items) {
- if (item.subject?.uri === uri) {
+ if (item.subject && didOrHandleUriMatches(atUri, item.subject)) {
yield item.subject
}
+
const quotedPost = getEmbeddedPost(item.subject?.embed)
- if (quotedPost?.uri === uri) {
- yield embedViewRecordToPostView(quotedPost)
+ if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
+ yield embedViewRecordToPostView(quotedPost!)
}
}
}
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index ebcdff6866..4662493533 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -145,7 +145,7 @@ async function fetchSubjects(
): Promise