diff --git a/src/components/dms/InitiateChatFlow.tsx b/src/components/dms/InitiateChatFlow.tsx
index 108041e67..161b0cf72 100644
--- a/src/components/dms/InitiateChatFlow.tsx
+++ b/src/components/dms/InitiateChatFlow.tsx
@@ -8,7 +8,7 @@ import {
} from 'react'
import {LayoutAnimation, type TextInput, View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
-import {Trans, useLingui} from '@lingui/react/macro'
+import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
@@ -600,8 +600,11 @@ export function InitiateChatFlow({
{color: t.palette.negative_400},
]}>
- Group name is too long. The maximum number of characters
- is {MAX_GROUP_NAME_GRAPHEME_LENGTH}.
+ Group name is too long.{' '}
+
) : null}
diff --git a/src/screens/Messages/ConversationSettings/prompts.tsx b/src/screens/Messages/ConversationSettings/prompts.tsx
index e8b594c0b..6ba5ea03b 100644
--- a/src/screens/Messages/ConversationSettings/prompts.tsx
+++ b/src/screens/Messages/ConversationSettings/prompts.tsx
@@ -1,5 +1,5 @@
import {View} from 'react-native'
-import {Trans, useLingui} from '@lingui/react/macro'
+import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants'
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
@@ -59,8 +59,11 @@ export function EditNamePrompt({
{color: t.palette.negative_400},
]}>
- Group name is too long. The maximum number of characters is{' '}
- {MAX_GROUP_NAME_GRAPHEME_LENGTH}.
+ Group name is too long.{' '}
+
) : null}
--
2.51.2
From 35258f1a168e5795795a4b17f608492c942d0531 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Mon, 8 Jun 2026 13:50:44 +0300
Subject: [PATCH 02/20] [Chat] Fix overflowing chat names (#10780)
---
.../Messages/components/MessagesListGroupInfoPanel.tsx | 2 +-
src/screens/Messages/components/MessagesListInfoPanel.tsx | 6 +++++-
2 files changed, 6 insertions(+), 2 deletions(-)
diff --git a/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx b/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx
index 72d92a24e..212918263 100644
--- a/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx
+++ b/src/screens/Messages/components/MessagesListGroupInfoPanel.tsx
@@ -94,7 +94,7 @@ export function MessagesListGroupInfoPanel({
/>
{convo.details.name ? (
+ style={[a.text_2xl, a.font_bold, a.mt_lg, a.px_xl, a.text_center]}>
{convo.details.name}
) : null}
diff --git a/src/screens/Messages/components/MessagesListInfoPanel.tsx b/src/screens/Messages/components/MessagesListInfoPanel.tsx
index 695fc74d3..b30bd6fe9 100644
--- a/src/screens/Messages/components/MessagesListInfoPanel.tsx
+++ b/src/screens/Messages/components/MessagesListInfoPanel.tsx
@@ -55,8 +55,12 @@ export function MessagesListInfoPanel({
a.justify_center,
a.gap_xs,
a.mt_lg,
+ a.px_xl,
+ a.max_w_full,
]}>
-
+
{displayName}
--
2.51.2
From c02f2a1eda66246ed76e91a6d12f440bc1c581c3 Mon Sep 17 00:00:00 2001
From: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Date: Mon, 8 Jun 2026 03:51:11 -0700
Subject: [PATCH 03/20] Fix alignment of error screen (#10763)
---
src/components/Error.tsx | 21 +++++++------------
src/components/Lists.tsx | 2 --
src/screens/Messages/Conversation.tsx | 1 -
.../Messages/ConversationSettings/index.tsx | 1 -
src/screens/Messages/JoinRequests.tsx | 1 -
5 files changed, 8 insertions(+), 18 deletions(-)
diff --git a/src/components/Error.tsx b/src/components/Error.tsx
index 77aacdb45..190165a2d 100644
--- a/src/components/Error.tsx
+++ b/src/components/Error.tsx
@@ -1,12 +1,10 @@
import {View} from 'react-native'
-import {msg} from '@lingui/core/macro'
-import {useLingui} from '@lingui/react'
-import {Trans} from '@lingui/react/macro'
+import {Trans, useLingui} from '@lingui/react/macro'
import {useGoBack} from '#/lib/hooks/useGoBack'
-import {CenteredView} from '#/view/com/util/Views'
import {atoms as a, useBreakpoints, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
+import * as Layout from '#/components/Layout'
import {Text} from '#/components/Typography'
export function Error({
@@ -15,22 +13,20 @@ export function Error({
onRetry,
onGoBack,
hideBackButton,
- sideBorders = true,
}: {
title?: string
message?: string
onRetry?: () => unknown
onGoBack?: () => unknown
hideBackButton?: boolean
- sideBorders?: boolean
}) {
- const {_} = useLingui()
+ const {t: l} = useLingui()
const t = useTheme()
const {gtMobile} = useBreakpoints()
const goBack = useGoBack(onGoBack)
return (
-
+ ]}>
{title}
@@ -70,7 +65,7 @@ export function Error({
)}
-
+
)
}
diff --git a/src/components/Lists.tsx b/src/components/Lists.tsx
index e1008300a..d0cb34215 100644
--- a/src/components/Lists.tsx
+++ b/src/components/Lists.tsx
@@ -185,7 +185,6 @@ let ListMaybePlaceholder = ({
message={errorMessage ?? _(msg`Something went wrong!`)}
onRetry={onRetry}
onGoBack={onGoBack}
- sideBorders={sideBorders}
hideBackButton={hideBackButton}
/>
)
@@ -226,7 +225,6 @@ let ListMaybePlaceholder = ({
onRetry={onRetry}
onGoBack={onGoBack}
hideBackButton={hideBackButton}
- sideBorders={sideBorders}
/>
)
}
diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx
index b26904653..36adb63e7 100644
--- a/src/screens/Messages/Conversation.tsx
+++ b/src/screens/Messages/Conversation.tsx
@@ -137,7 +137,6 @@ function Inner({convoId}: {convoId: string}) {
title={l`Something went wrong`}
message={l`We couldn't load this conversation`}
onRetry={() => convoState.error.retry()}
- sideBorders={false}
/>
>
)
diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx
index 5e4ad9142..fbc4ffcc6 100644
--- a/src/screens/Messages/ConversationSettings/index.tsx
+++ b/src/screens/Messages/ConversationSettings/index.tsx
@@ -112,7 +112,6 @@ function SettingsInner() {
title={l`Something went wrong`}
message={l`We couldn’t load this conversation’s settings`}
onRetry={() => convoState.error.retry()}
- sideBorders={false}
/>
)
}
diff --git a/src/screens/Messages/JoinRequests.tsx b/src/screens/Messages/JoinRequests.tsx
index 98952aee3..5198cd7f1 100644
--- a/src/screens/Messages/JoinRequests.tsx
+++ b/src/screens/Messages/JoinRequests.tsx
@@ -87,7 +87,6 @@ function JoinRequestsInner() {
title={l`Something went wrong`}
message={l`We couldn’t load this conversation’s join requests`}
onRetry={() => convoState.error.retry()}
- sideBorders={false}
/>
>
)
--
2.51.2
From e60e31733af4b7c82c22b3b0f4f1e9d54a343c9d Mon Sep 17 00:00:00 2001
From: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Date: Mon, 8 Jun 2026 04:36:34 -0700
Subject: [PATCH 04/20] Use new listConvoRequests endpoint (#10755)
Co-authored-by: Samuel Newman
---
eslint-suppressions.json | 10 -
src/components/AvatarBubbles.tsx | 6 +-
src/screens/Messages/Inbox.tsx | 63 ++--
...stItem.tsx => IncomingRequestListItem.tsx} | 2 +-
.../components/OutgoingRequestListItem.tsx | 128 +++++++
src/state/cache/profile-shadow.ts | 2 +
.../queries/messages/accept-conversation.ts | 26 +-
.../messages/list-conversation-requests.tsx | 165 +++++++++
.../queries/messages/list-conversations.tsx | 346 +++++++++++-------
.../messages/request-join-group-chat.ts | 5 +-
src/state/queries/messages/update-all-read.ts | 39 +-
.../messages/withdraw-join-group-chat.ts | 14 +-
12 files changed, 627 insertions(+), 179 deletions(-)
rename src/screens/Messages/components/{RequestListItem.tsx => IncomingRequestListItem.tsx} (98%)
create mode 100644 src/screens/Messages/components/OutgoingRequestListItem.tsx
create mode 100644 src/state/queries/messages/list-conversation-requests.tsx
diff --git a/eslint-suppressions.json b/eslint-suppressions.json
index 5bd3357d1..304fd4a06 100644
--- a/eslint-suppressions.json
+++ b/eslint-suppressions.json
@@ -1773,16 +1773,6 @@
"count": 7
}
},
- "src/state/queries/messages/accept-conversation.ts": {
- "@typescript-eslint/no-floating-promises": {
- "count": 2
- }
- },
- "src/state/queries/messages/update-all-read.ts": {
- "@typescript-eslint/no-floating-promises": {
- "count": 3
- }
- },
"src/state/queries/my-lists.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 2
diff --git a/src/components/AvatarBubbles.tsx b/src/components/AvatarBubbles.tsx
index c49dada16..2f3fb8b15 100644
--- a/src/components/AvatarBubbles.tsx
+++ b/src/components/AvatarBubbles.tsx
@@ -36,7 +36,7 @@ export function AvatarBubbles({
moderationOpts,
}: {
animate?: boolean
- profiles: bsky.profile.AnyProfileView[]
+ profiles: (bsky.profile.AnyProfileView | undefined)[]
/**
* By default, when there are more than 2 profiles, the current user is
* filtered out (so you don't see yourself among your own group's members).
@@ -50,12 +50,12 @@ export function AvatarBubbles({
const {currentAccount} = useSession()
const profiles =
!self && allProfiles.length > 2
- ? allProfiles.filter(p => p?.did != null && p.did !== currentAccount?.did)
+ ? allProfiles.filter(p => !p || p.did !== currentAccount?.did)
: allProfiles
const moderations = useMemo(() => {
if (!moderationOpts) return []
return profiles.map(p => {
- return moderateProfile(p, moderationOpts)
+ return p && moderateProfile(p, moderationOpts)
})
}, [profiles, moderationOpts])
diff --git a/src/screens/Messages/Inbox.tsx b/src/screens/Messages/Inbox.tsx
index 59ca05bdb..60891c3c1 100644
--- a/src/screens/Messages/Inbox.tsx
+++ b/src/screens/Messages/Inbox.tsx
@@ -1,8 +1,9 @@
import {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {
- type ChatBskyConvoDefs,
- type ChatBskyConvoListConvos,
+ ChatBskyConvoDefs,
+ type ChatBskyConvoListConvoRequests,
+ ChatBskyGroupDefs,
} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useFocusEffect, useNavigation} from '@react-navigation/native'
@@ -23,7 +24,7 @@ import {logger} from '#/logger'
import {MESSAGE_SCREEN_POLL_INTERVAL} from '#/state/messages/convo/const'
import {useMessagesEventBus} from '#/state/messages/events'
import {useLeftConvos} from '#/state/queries/messages/leave-conversation'
-import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
+import {useListConvoRequests} from '#/state/queries/messages/list-conversation-requests'
import {useUpdateAllRead} from '#/state/queries/messages/update-all-read'
import {EmptyState} from '#/view/com/util/EmptyState'
import {List} from '#/view/com/util/List'
@@ -43,11 +44,16 @@ import {ListFooter} from '#/components/Lists'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {IS_NATIVE} from '#/env'
-import {RequestListItem} from './components/RequestListItem'
+import {IncomingRequestListItem} from './components/IncomingRequestListItem'
+import {OutgoingRequestListItem} from './components/OutgoingRequestListItem'
import {useIsWithinSplitView} from './components/splitView/context'
type Props = NativeStackScreenProps
+type RequestItem =
+ | {type: 'incoming'; view: ChatBskyConvoDefs.ConvoView}
+ | {type: 'outgoing'; view: ChatBskyGroupDefs.JoinRequestConvoView}
+
export function MessagesInboxScreen(props: Props) {
const {t: l} = useLingui()
const aaCopy = useAgeAssuranceCopy()
@@ -61,29 +67,36 @@ export function MessagesInboxScreen(props: Props) {
}
export function MessagesInboxScreenInner({}: Props) {
- const listConvosQuery = useListConvosQuery({status: 'request'})
+ const listConvosQuery = useListConvoRequests()
const {data} = listConvosQuery
const leftConvos = useLeftConvos()
- const conversations = useMemo(() => {
- if (data?.pages) {
- const convos = data.pages
- .flatMap(page => page.convos)
- // filter out convos that are actively being left
- .filter(convo => !leftConvos.includes(convo.id))
-
- return convos
+ const conversations = useMemo(() => {
+ if (!data?.pages) return []
+ const items: RequestItem[] = []
+ for (const page of data.pages) {
+ for (const item of page.requests) {
+ if (ChatBskyConvoDefs.isConvoView(item)) {
+ // filter out convos that are actively being left
+ if (leftConvos.includes(item.id)) continue
+ items.push({type: 'incoming', view: item})
+ } else if (ChatBskyGroupDefs.isJoinRequestConvoView(item)) {
+ items.push({type: 'outgoing', view: item})
+ }
+ }
}
- return []
+ return items
}, [data, leftConvos])
const hasUnreadConvos = useMemo(() => {
return conversations.some(
- conversation =>
- conversation.members.every(
+ item =>
+ item.type === 'incoming' &&
+ item.view.members.every(
member => member.handle !== 'missing.invalid',
- ) && conversation.unreadCount > 0,
+ ) &&
+ item.view.unreadCount > 0,
)
}, [conversations])
@@ -111,10 +124,10 @@ function RequestList({
conversations,
}: {
listConvosQuery: UseInfiniteQueryResult<
- InfiniteData,
+ InfiniteData,
Error
>
- conversations: ChatBskyConvoDefs.ConvoView[]
+ conversations: RequestItem[]
}) {
const {t: l} = useLingui()
const t = useTheme()
@@ -274,17 +287,21 @@ function RequestList({
windowSize={11}
desktopFixedHeight
sideBorders={false}
+ contentContainerStyle={[web(a.py_sm)]}
/>
>
)
}
-function keyExtractor(item: ChatBskyConvoDefs.ConvoView) {
- return item.id
+function keyExtractor(item: RequestItem) {
+ return item.type === 'incoming' ? item.view.id : item.view.convoId
}
-function renderItem({item}: {item: ChatBskyConvoDefs.ConvoView}) {
- return
+function renderItem({item}: {item: RequestItem}) {
+ if (item.type === 'incoming') {
+ return
+ }
+ return
}
function MarkAsReadHeaderButton() {
diff --git a/src/screens/Messages/components/RequestListItem.tsx b/src/screens/Messages/components/IncomingRequestListItem.tsx
similarity index 98%
rename from src/screens/Messages/components/RequestListItem.tsx
rename to src/screens/Messages/components/IncomingRequestListItem.tsx
index 79eab480a..2ddc8d199 100644
--- a/src/screens/Messages/components/RequestListItem.tsx
+++ b/src/screens/Messages/components/IncomingRequestListItem.tsx
@@ -11,7 +11,7 @@ import {Text} from '#/components/Typography'
import {ChatListItem, ChatListItemPortal} from './ChatListItem'
import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons'
-export function RequestListItem({
+export function IncomingRequestListItem({
convo: convoView,
}: {
convo: ChatBskyConvoDefs.ConvoView
diff --git a/src/screens/Messages/components/OutgoingRequestListItem.tsx b/src/screens/Messages/components/OutgoingRequestListItem.tsx
new file mode 100644
index 000000000..8bd7ba78d
--- /dev/null
+++ b/src/screens/Messages/components/OutgoingRequestListItem.tsx
@@ -0,0 +1,128 @@
+import {View} from 'react-native'
+import {
+ type ChatBskyGroupDefs,
+ ChatBskyGroupWithdrawJoinRequest,
+} from '@atproto/api'
+import {Trans, useLingui} from '@lingui/react/macro'
+
+import {isNetworkError} from '#/lib/strings/errors'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useWithdrawJoinGroupChatRequest} from '#/state/queries/messages/withdraw-join-group-chat'
+import {TimeElapsed} from '#/view/com/util/TimeElapsed'
+import {atoms as a, useTheme, web} from '#/alf'
+import {AvatarBubbles} from '#/components/AvatarBubbles'
+import {createStaticClick, Link} from '#/components/Link'
+import * as Prompt from '#/components/Prompt'
+import * as Toast from '#/components/Toast'
+import {Text} from '#/components/Typography'
+
+export function OutgoingRequestListItem({
+ convo: convoView,
+}: {
+ convo: ChatBskyGroupDefs.JoinRequestConvoView
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const prompt = Prompt.usePromptControl()
+
+ const moderationOpts = useModerationOpts()
+
+ const {mutate: withdrawRequest, isPending: isWithdrawPending} =
+ useWithdrawJoinGroupChatRequest({
+ onSuccess: () => {
+ Toast.show(l`Join request rescinded.`)
+ },
+ onError: error => {
+ let errorMessage = l`Failed to rescind your request. Please try again.`
+ if (isNetworkError(error)) {
+ errorMessage = l`There was a problem with your internet connection, please try again`
+ } else if (
+ error instanceof
+ ChatBskyGroupWithdrawJoinRequest.InvalidJoinRequestError
+ ) {
+ errorMessage = l`Invalid rescind request.`
+ }
+ Toast.show(errorMessage)
+ },
+ })
+
+ return (
+ <>
+ {
+ prompt.open()
+ })}>
+ {({hovered, pressed, focused}) => (
+
+
+
+
+
+
+ {convoView.name}
+
+
+
+
+ {({timeElapsed}) => (
+
+ {timeElapsed}
+
+ )}
+
+
+
+
+
+ You requested to join
+
+
+
+
+ )}
+
+ {
+ prompt.close(() => {
+ if (isWithdrawPending) return
+ withdrawRequest({convoId: convoView.convoId})
+ })
+ }}
+ />
+ >
+ )
+}
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index 8b61b4416..f387306f4 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -11,6 +11,7 @@ import {findAllProfilesInQueryData as findAllProfilesInContactMatchesQueryData}
import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers'
import {findAllProfilesInQueryData as findAllProfilesInListMembersQueryData} from '#/state/queries/list-members'
import {findAllProfilesInQueryData as findAllProfilesInGetConvoQueryData} from '#/state/queries/messages/conversation'
+import {findAllProfilesInQueryData as findAllProfilesInListConvoRequestsQueryData} from '#/state/queries/messages/list-conversation-requests'
import {findAllProfilesInQueryData as findAllProfilesInListConvosQueryData} from '#/state/queries/messages/list-conversations'
import {findAllProfilesInQueryData as findAllProfilesInMessagesQueryData} from '#/state/queries/messages/list-convo-members'
import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryData} from '#/state/queries/my-blocked-accounts'
@@ -259,6 +260,7 @@ function* findProfilesInCache(
yield* findAllProfilesInSuggestedFollowsQueryData(queryClient, did)
yield* findAllProfilesInActorSearchQueryData(queryClient, did)
yield* findAllProfilesInListConvosQueryData(queryClient, did)
+ yield* findAllProfilesInListConvoRequestsQueryData(queryClient, did)
yield* findAllProfilesInFeedsQueryData(queryClient, did)
yield* findAllProfilesInPostThreadV2QueryData(queryClient, did)
yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
diff --git a/src/state/queries/messages/accept-conversation.ts b/src/state/queries/messages/accept-conversation.ts
index 0c06055b5..6f1b3d272 100644
--- a/src/state/queries/messages/accept-conversation.ts
+++ b/src/state/queries/messages/accept-conversation.ts
@@ -7,6 +7,11 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
+import {
+ type ConvoRequestListQueryData,
+ optimisticDelete as optimisticDeleteRequest,
+ RQKEY_ROOT as REQUESTS_RQKEY_ROOT,
+} from './list-conversation-requests'
import {
RQKEY as CONVO_LIST_KEY,
RQKEY_ROOT as CONVO_LIST_ROOT_KEY,
@@ -96,11 +101,20 @@ export function useAcceptConversation(
}
},
)
+ const prevRequestsQueries =
+ queryClient.getQueriesData({
+ queryKey: [REQUESTS_RQKEY_ROOT],
+ })
+ queryClient.setQueriesData(
+ {queryKey: [REQUESTS_RQKEY_ROOT]},
+ old => optimisticDeleteRequest(convoId, old),
+ )
onMutate?.()
- return {prevAcceptedPages, prevInboxPages}
+ return {prevAcceptedPages, prevInboxPages, prevRequestsQueries}
},
onSuccess: data => {
- queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
+ void queryClient.invalidateQueries({queryKey: [CONVO_LIST_KEY]})
+ void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
onSuccess?.(data)
},
onError: (error, _, context) => {
@@ -131,7 +145,13 @@ export function useAcceptConversation(
}
},
)
- queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
+ if (context?.prevRequestsQueries) {
+ for (const [queryKey, prevData] of context.prevRequestsQueries) {
+ queryClient.setQueryData(queryKey, prevData)
+ }
+ }
+ void queryClient.invalidateQueries({queryKey: [CONVO_LIST_ROOT_KEY]})
+ void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
onError?.(error)
},
})
diff --git a/src/state/queries/messages/list-conversation-requests.tsx b/src/state/queries/messages/list-conversation-requests.tsx
new file mode 100644
index 000000000..10023db4e
--- /dev/null
+++ b/src/state/queries/messages/list-conversation-requests.tsx
@@ -0,0 +1,165 @@
+import {
+ ChatBskyConvoDefs,
+ type ChatBskyConvoListConvoRequests,
+ ChatBskyGroupDefs,
+} from '@atproto/api'
+import {
+ type InfiniteData,
+ type QueryClient,
+ useInfiniteQuery,
+} from '@tanstack/react-query'
+
+import {DM_SERVICE_HEADERS} from '#/lib/constants'
+import {useAgent} from '#/state/session'
+
+const DEFAULT_LIMIT = 10
+
+export const RQKEY_ROOT = 'convo-request-list'
+export const RQKEY = (limit: number = DEFAULT_LIMIT) => [RQKEY_ROOT, limit]
+
+type RQPageParam = string | undefined
+
+export function useListConvoRequests({
+ enabled = true,
+ limit = DEFAULT_LIMIT,
+}: {
+ enabled?: boolean
+ limit?: number
+} = {}) {
+ const agent = useAgent()
+
+ return useInfiniteQuery({
+ enabled,
+ queryKey: RQKEY(limit),
+ queryFn: async ({pageParam}) => {
+ const {data} = await agent.chat.bsky.convo.listConvoRequests(
+ {limit, cursor: pageParam},
+ {headers: DM_SERVICE_HEADERS},
+ )
+ return data
+ },
+ initialPageParam: undefined as RQPageParam,
+ getNextPageParam: lastPage => lastPage.cursor,
+ })
+}
+
+export type ConvoRequestListQueryData = {
+ pageParams: Array
+ pages: Array
+}
+
+export type ConvoRequestItem =
+ ChatBskyConvoListConvoRequests.OutputSchema['requests'][number]
+
+export function optimisticUpdate(
+ chatId: string,
+ old: ConvoRequestListQueryData | undefined,
+ updateFn: (convo: ChatBskyConvoDefs.ConvoView) => ChatBskyConvoDefs.ConvoView,
+): ConvoRequestListQueryData | undefined {
+ if (!old) return old
+
+ return {
+ ...old,
+ pages: old.pages.map(page => ({
+ ...page,
+ requests: page.requests.map((item): ConvoRequestItem => {
+ if (ChatBskyConvoDefs.isConvoView(item) && item.id === chatId) {
+ return {
+ ...updateFn(item),
+ $type: 'chat.bsky.convo.defs#convoView',
+ }
+ }
+ return item
+ }),
+ })),
+ }
+}
+
+export function optimisticDelete(
+ chatId: string,
+ old: ConvoRequestListQueryData | undefined,
+) {
+ if (!old) return old
+
+ return {
+ ...old,
+ pages: old.pages.map(page => ({
+ ...page,
+ requests: page.requests.filter(
+ item => !ChatBskyConvoDefs.isConvoView(item) || item.id !== chatId,
+ ),
+ })),
+ }
+}
+
+export function markAllRead(
+ old: ConvoRequestListQueryData | undefined,
+): ConvoRequestListQueryData | undefined {
+ if (!old) return old
+
+ return {
+ ...old,
+ pages: old.pages.map(page => ({
+ ...page,
+ requests: page.requests.map((item): ConvoRequestItem => {
+ if (ChatBskyConvoDefs.isConvoView(item)) {
+ return {
+ ...item,
+ $type: 'chat.bsky.convo.defs#convoView',
+ unreadCount: 0,
+ }
+ }
+ return item
+ }),
+ })),
+ }
+}
+
+export function optimisticDeleteJoinRequest(
+ convoId: string,
+ old: ConvoRequestListQueryData | undefined,
+) {
+ if (!old) return old
+
+ return {
+ ...old,
+ pages: old.pages.map(page => ({
+ ...page,
+ requests: page.requests.filter(
+ item =>
+ !ChatBskyGroupDefs.isJoinRequestConvoView(item) ||
+ item.convoId !== convoId,
+ ),
+ })),
+ }
+}
+
+export function* findAllProfilesInQueryData(
+ queryClient: QueryClient,
+ did: string,
+) {
+ const queryDatas = queryClient.getQueriesData<
+ InfiniteData
+ >({
+ queryKey: [RQKEY_ROOT],
+ })
+ for (const [_queryKey, queryData] of queryDatas) {
+ if (!queryData?.pages) continue
+
+ for (const page of queryData.pages) {
+ for (const item of page.requests) {
+ if (ChatBskyConvoDefs.isConvoView(item)) {
+ for (const member of item.members) {
+ if (member.did === did) {
+ yield member
+ }
+ }
+ } else if (ChatBskyGroupDefs.isJoinRequestConvoView(item)) {
+ if (item.owner.did === did) {
+ yield item.owner
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx
index dadf30bcd..372ef708f 100644
--- a/src/state/queries/messages/list-conversations.tsx
+++ b/src/state/queries/messages/list-conversations.tsx
@@ -23,6 +23,13 @@ import {parseConvoView} from '#/components/dms/util'
import * as bsky from '#/types/bsky'
import {RQKEY as CONVO_KEY} from './conversation'
import {useLeftConvos} from './leave-conversation'
+import {
+ type ConvoRequestListQueryData,
+ optimisticDelete as optimisticDeleteRequest,
+ optimisticDeleteJoinRequest,
+ optimisticUpdate as optimisticUpdateRequest,
+ RQKEY_ROOT as REQUESTS_RQKEY_ROOT,
+} from './list-conversation-requests'
import {listConvoMembersQueryKey} from './list-convo-members'
const DEFAULT_LIMIT = 10
@@ -130,6 +137,7 @@ export function ListConvosProviderInner({
const refetchAndInvalidate = () => {
void refetch()
void queryClient.invalidateQueries({queryKey: [RQKEY_ROOT]})
+ void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
}
return throttle(refetchAndInvalidate, 500, {
leading: true,
@@ -157,6 +165,22 @@ export function ListConvosProviderInner({
)
}
+ function updateConvoInAllLists(
+ convoId: string,
+ fn: (
+ convo: ChatBskyConvoDefs.ConvoView,
+ ) => ChatBskyConvoDefs.ConvoView,
+ ) {
+ queryClient.setQueriesData(
+ {queryKey: [RQKEY_ROOT]},
+ old => optimisticUpdate(convoId, old, fn),
+ )
+ queryClient.setQueriesData(
+ {queryKey: [REQUESTS_RQKEY_ROOT]},
+ old => optimisticUpdateRequest(convoId, old, fn),
+ )
+ }
+
function mutateConvoView(
convoId: string,
fn: (
@@ -167,9 +191,17 @@ export function ListConvosProviderInner({
CONVO_KEY(convoId),
old => (old ? fn(old) : old),
)
+ updateConvoInAllLists(convoId, fn)
+ }
+
+ function deleteConvoFromAllLists(convoId: string) {
queryClient.setQueriesData(
{queryKey: [RQKEY_ROOT]},
- old => optimisticUpdate(convoId, old, fn),
+ old => optimisticDelete(convoId, old),
+ )
+ queryClient.setQueriesData(
+ {queryKey: [REQUESTS_RQKEY_ROOT]},
+ old => optimisticDeleteRequest(convoId, old),
)
}
@@ -220,35 +252,26 @@ export function ListConvosProviderInner({
if (ChatBskyConvoDefs.isLogBeginConvo(log)) {
debouncedRefetch()
} else if (ChatBskyConvoDefs.isLogLeaveConvo(log)) {
- queryClient.setQueriesData(
- {queryKey: [RQKEY_ROOT]},
- (old?: ConvoListQueryData) => optimisticDelete(log.convoId, old),
- )
+ deleteConvoFromAllLists(log.convoId)
} else if (ChatBskyConvoDefs.isLogDeleteMessage(log)) {
- queryClient.setQueriesData(
- {queryKey: [RQKEY_ROOT]},
- (old?: ConvoListQueryData) =>
- optimisticUpdate(log.convoId, old, convo => {
- if (
- (ChatBskyConvoDefs.isDeletedMessageView(log.message) ||
- ChatBskyConvoDefs.isMessageView(log.message)) &&
- (ChatBskyConvoDefs.isDeletedMessageView(
- convo.lastMessage,
- ) ||
- ChatBskyConvoDefs.isMessageView(convo.lastMessage))
- ) {
- return log.message.id === convo.lastMessage.id
- ? {
- ...convo,
- rev: log.rev,
- lastMessage: log.message,
- }
- : convo
- } else {
- return convo
- }
- }),
- )
+ updateConvoInAllLists(log.convoId, convo => {
+ if (
+ (ChatBskyConvoDefs.isDeletedMessageView(log.message) ||
+ ChatBskyConvoDefs.isMessageView(log.message)) &&
+ (ChatBskyConvoDefs.isDeletedMessageView(convo.lastMessage) ||
+ ChatBskyConvoDefs.isMessageView(convo.lastMessage))
+ ) {
+ return log.message.id === convo.lastMessage.id
+ ? {
+ ...convo,
+ rev: log.rev,
+ lastMessage: log.message,
+ }
+ : convo
+ } else {
+ return convo
+ }
+ })
} else if (ChatBskyConvoDefs.isLogCreateMessage(log)) {
// Store in a new var to avoid TS errors due to closures.
const logRef: ChatBskyConvoDefs.LogCreateMessage = log
@@ -335,27 +358,24 @@ export function ListConvosProviderInner({
)
} else if (updatedConvo.status === 'request') {
queryClient.setQueriesData({queryKey: RQKEY('request')}, updateFn)
+ // also move-to-top in the new requests cache
+ queryClient.setQueriesData(
+ {queryKey: [REQUESTS_RQKEY_ROOT]},
+ old => moveConvoToTopInRequests(updatedConvo, old),
+ )
}
} else if (ChatBskyConvoDefs.isLogReadMessage(log)) {
- queryClient.setQueriesData(
- {queryKey: [RQKEY_ROOT]},
- (old?: ConvoListQueryData) =>
- optimisticUpdate(log.convoId, old, convo => ({
- ...convo,
- unreadCount: 0,
- rev: log.rev,
- })),
- )
+ updateConvoInAllLists(log.convoId, convo => ({
+ ...convo,
+ unreadCount: 0,
+ rev: log.rev,
+ }))
} else if (ChatBskyConvoDefs.isLogReadConvo(log)) {
- queryClient.setQueriesData(
- {queryKey: [RQKEY_ROOT]},
- (old?: ConvoListQueryData) =>
- optimisticUpdate(log.convoId, old, convo => ({
- ...convo,
- unreadCount: 0,
- rev: log.rev,
- })),
- )
+ updateConvoInAllLists(log.convoId, convo => ({
+ ...convo,
+ unreadCount: 0,
+ rev: log.rev,
+ }))
} else if (ChatBskyConvoDefs.isLogAcceptConvo(log)) {
const requests = queryClient.getQueryData(
RQKEY('request'),
@@ -373,6 +393,11 @@ export function ListConvosProviderInner({
RQKEY('request'),
(old?: ConvoListQueryData) => optimisticDelete(log.convoId, old),
)
+ // also remove from the new requests cache
+ queryClient.setQueriesData(
+ {queryKey: [REQUESTS_RQKEY_ROOT]},
+ old => optimisticDeleteRequest(log.convoId, old),
+ )
queryClient.setQueriesData(
{queryKey: RQKEY('accepted')},
(old?: ConvoListQueryData) => {
@@ -398,55 +423,50 @@ export function ListConvosProviderInner({
},
)
} else if (ChatBskyConvoDefs.isLogMuteConvo(log)) {
- queryClient.setQueriesData(
- {queryKey: [RQKEY_ROOT]},
- (old?: ConvoListQueryData) =>
- optimisticUpdate(log.convoId, old, convo => ({
- ...convo,
- muted: true,
- rev: log.rev,
- })),
- )
+ updateConvoInAllLists(log.convoId, convo => ({
+ ...convo,
+ muted: true,
+ rev: log.rev,
+ }))
} else if (ChatBskyConvoDefs.isLogUnmuteConvo(log)) {
- queryClient.setQueriesData(
- {queryKey: [RQKEY_ROOT]},
- (old?: ConvoListQueryData) =>
- optimisticUpdate(log.convoId, old, convo => ({
+ updateConvoInAllLists(log.convoId, convo => ({
+ ...convo,
+ muted: false,
+ rev: log.rev,
+ }))
+ } else if (ChatBskyConvoDefs.isLogLockConvo(log)) {
+ mutateConvoView(log.convoId, convo => {
+ if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
+ return {
...convo,
- muted: false,
+ kind: {...convo.kind, lockStatus: 'locked'},
rev: log.rev,
- })),
- )
- } else if (ChatBskyConvoDefs.isLogLockConvo(log)) {
- mutateConvoView(log.convoId, convo =>
- ChatBskyConvoDefs.isGroupConvo(convo.kind)
- ? {
- ...convo,
- kind: {...convo.kind, lockStatus: 'locked'},
- rev: log.rev,
- }
- : {...convo, rev: log.rev},
- )
+ }
+ }
+ return {...convo, rev: log.rev}
+ })
} else if (ChatBskyConvoDefs.isLogUnlockConvo(log)) {
- mutateConvoView(log.convoId, convo =>
- ChatBskyConvoDefs.isGroupConvo(convo.kind)
- ? {
- ...convo,
- kind: {...convo.kind, lockStatus: 'unlocked'},
- rev: log.rev,
- }
- : {...convo, rev: log.rev},
- )
+ mutateConvoView(log.convoId, convo => {
+ if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
+ return {
+ ...convo,
+ kind: {...convo.kind, lockStatus: 'unlocked'},
+ rev: log.rev,
+ }
+ }
+ return {...convo, rev: log.rev}
+ })
} else if (ChatBskyConvoDefs.isLogLockConvoPermanently(log)) {
- mutateConvoView(log.convoId, convo =>
- ChatBskyConvoDefs.isGroupConvo(convo.kind)
- ? {
- ...convo,
- kind: {...convo.kind, lockStatus: 'locked-permanently'},
- rev: log.rev,
- }
- : {...convo, rev: log.rev},
- )
+ mutateConvoView(log.convoId, convo => {
+ if (ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
+ return {
+ ...convo,
+ kind: {...convo.kind, lockStatus: 'locked-permanently'},
+ rev: log.rev,
+ }
+ }
+ return {...convo, rev: log.rev}
+ })
} else if (
ChatBskyConvoDefs.isLogCreateJoinLink(log) ||
ChatBskyConvoDefs.isLogEditJoinLink(log) ||
@@ -455,37 +475,68 @@ export function ListConvosProviderInner({
) {
// Join link data not included in the log event, trigger refetch to get it
debouncedRefetch()
+ } else if (ChatBskyConvoDefs.isLogEditGroup(log)) {
+ // Updated group details (name etc.) aren't included in the log
+ // event, so refetch to pick them up.
+ debouncedRefetch()
} else if (
ChatBskyConvoDefs.isLogApproveJoinRequest(log) ||
ChatBskyConvoDefs.isLogRejectJoinRequest(log)
) {
- queryClient.setQueriesData(
- {queryKey: [RQKEY_ROOT]},
- (old?: ConvoListQueryData) =>
- updateGroupConvoJoinRequestCount(log, old, -1),
+ // Route through mutateConvoView (not updateConvoInAllLists) so the
+ // single-convo cache updates too, keeping the in-convo requests
+ // banner in sync.
+ mutateConvoView(log.convoId, convo =>
+ applyJoinRequestCountDelta(convo, log.rev, -1),
)
} else if (ChatBskyConvoDefs.isLogIncomingJoinRequest(log)) {
- queryClient.setQueriesData(
- {queryKey: [RQKEY_ROOT]},
- (old?: ConvoListQueryData) =>
- updateGroupConvoJoinRequestCount(log, old, 1),
+ // Route through mutateConvoView (not updateConvoInAllLists) so the
+ // single-convo cache updates too, letting the in-convo requests
+ // banner appear live.
+ mutateConvoView(log.convoId, convo =>
+ applyJoinRequestCountDelta(convo, log.rev, 1),
)
+ } else if (ChatBskyConvoDefs.isLogReadJoinRequests(log)) {
+ // The owner marked join requests as read (possibly on another
+ // device). Zero the unread count but keep the total, mirroring the
+ // useMarkJoinRequestsRead mutation.
+ mutateConvoView(log.convoId, convo => {
+ if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
+ return {...convo, rev: log.rev}
+ }
+ return {
+ ...convo,
+ kind: {...convo.kind, unreadJoinRequestCount: 0},
+ rev: log.rev,
+ }
+ })
} else if (ChatBskyConvoDefs.isLogOutgoingJoinRequest(log)) {
- // Viewer isn't in the chat yet, no need to do anything
- } else if (ChatBskyConvoDefs.isLogAddReaction(log)) {
- queryClient.setQueriesData(
- {queryKey: [RQKEY_ROOT]},
- (old?: ConvoListQueryData) =>
- optimisticUpdate(log.convoId, old, convo => ({
- ...convo,
- lastReaction: {
- $type: 'chat.bsky.convo.defs#messageAndReactionView',
- reaction: log.reaction,
- message: log.message,
- },
- rev: log.rev,
- })),
+ // Viewer isn't in the chat yet, but the inbox surfaces outgoing
+ // requests, so refetch to pick up the new entry.
+ debouncedRefetch()
+ } else if (ChatBskyConvoDefs.isLogWithdrawIncomingJoinRequest(log)) {
+ // A requester rescinded their request to a group the viewer owns.
+ // Mirror of isLogIncomingJoinRequest: decrement the counts.
+ mutateConvoView(log.convoId, convo =>
+ applyJoinRequestCountDelta(convo, log.rev, -1),
)
+ } else if (ChatBskyConvoDefs.isLogWithdrawOutgoingJoinRequest(log)) {
+ // The viewer rescinded their own outgoing join request (possibly on
+ // another device). Remove it from the requests inbox cache.
+ queryClient.setQueriesData(
+ {queryKey: [REQUESTS_RQKEY_ROOT]},
+ old => optimisticDeleteJoinRequest(log.convoId, old),
+ )
+ } else if (ChatBskyConvoDefs.isLogAddReaction(log)) {
+ updateConvoInAllLists(log.convoId, convo => ({
+ ...convo,
+ lastReaction: {
+ $type: 'chat.bsky.convo.defs#messageAndReactionView',
+ reaction: log.reaction,
+ message: log.message,
+ },
+ rev: log.rev,
+ }))
} else if (ChatBskyConvoDefs.isLogAddMember(log)) {
const data = log.message.data
if (
@@ -739,27 +790,58 @@ function optimisticUpdate(
}
}
-function updateGroupConvoJoinRequestCount(
- log: {convoId: string; rev: string},
- old: ConvoListQueryData | undefined,
+function applyJoinRequestCountDelta(
+ convo: ChatBskyConvoDefs.ConvoView,
+ rev: string,
delta: 1 | -1,
-) {
- return optimisticUpdate(log.convoId, old, convo => {
- // Join requests are only meaningful for group convos.
- if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
- return {...convo, rev: log.rev}
- }
- const current = convo.kind.joinRequestCount ?? 0
- const next = Math.max(0, current + delta)
- return {
- ...convo,
- kind: {
- ...convo.kind,
- joinRequestCount: next === 0 ? undefined : next,
- },
- rev: log.rev,
+): ChatBskyConvoDefs.ConvoView {
+ // Join requests are only meaningful for group convos.
+ if (!ChatBskyConvoDefs.isGroupConvo(convo.kind)) {
+ return {...convo, rev}
+ }
+ // Bump the total and unread counts together. Both are clamped at 0 and
+ // collapse to undefined when empty, matching the server's shape.
+ const bump = (current: number | undefined) => {
+ const next = Math.max(0, (current ?? 0) + delta)
+ return next === 0 ? undefined : next
+ }
+ return {
+ ...convo,
+ kind: {
+ ...convo.kind,
+ joinRequestCount: bump(convo.kind.joinRequestCount),
+ unreadJoinRequestCount: bump(convo.kind.unreadJoinRequestCount),
+ },
+ rev,
+ }
+}
+
+function moveConvoToTopInRequests(
+ updatedConvo: ChatBskyConvoDefs.ConvoView,
+ old: ConvoRequestListQueryData | undefined,
+): ConvoRequestListQueryData | undefined {
+ if (!old) return old
+ const typedConvo: ConvoRequestListQueryData['pages'][number]['requests'][number] =
+ {
+ $type: 'chat.bsky.convo.defs#convoView',
+ ...updatedConvo,
}
- })
+ return {
+ ...old,
+ pages: old.pages.map((page, i) => {
+ const filtered = page.requests.filter(
+ item =>
+ !ChatBskyConvoDefs.isConvoView(item) || item.id !== updatedConvo.id,
+ )
+ if (i === 0) {
+ return {
+ ...page,
+ requests: [typedConvo, ...filtered],
+ }
+ }
+ return {...page, requests: filtered}
+ }),
+ }
}
function removeMemberFromConvoView(
diff --git a/src/state/queries/messages/request-join-group-chat.ts b/src/state/queries/messages/request-join-group-chat.ts
index 04e63395f..f4d565034 100644
--- a/src/state/queries/messages/request-join-group-chat.ts
+++ b/src/state/queries/messages/request-join-group-chat.ts
@@ -1,9 +1,10 @@
import {type ChatBskyGroupRequestJoin} from '@atproto/api'
-import {useMutation} from '@tanstack/react-query'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
+import {RQKEY_ROOT as REQUESTS_RQKEY_ROOT} from './list-conversation-requests'
export function useRequestJoinGroupChat({
onSuccess,
@@ -13,6 +14,7 @@ export function useRequestJoinGroupChat({
onError?: (error: Error) => void
} = {}) {
const agent = useAgent()
+ const queryClient = useQueryClient()
const {hasSession} = useSession()
return useMutation({
@@ -27,6 +29,7 @@ export function useRequestJoinGroupChat({
return res.data
},
onSuccess: data => {
+ void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
onSuccess?.(data)
},
onError: error => {
diff --git a/src/state/queries/messages/update-all-read.ts b/src/state/queries/messages/update-all-read.ts
index 3d0fd3a45..79173362d 100644
--- a/src/state/queries/messages/update-all-read.ts
+++ b/src/state/queries/messages/update-all-read.ts
@@ -4,6 +4,11 @@ import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
+import {
+ type ConvoRequestListQueryData,
+ markAllRead as markAllRequestsRead,
+ RQKEY_ROOT as REQUESTS_RQKEY_ROOT,
+} from './list-conversation-requests'
import {RQKEY as CONVO_LIST_KEY} from './list-conversations'
export function useUpdateAllRead(
@@ -32,6 +37,9 @@ export function useUpdateAllRead(
},
onMutate: () => {
let prevPages: ChatBskyConvoListConvos.OutputSchema[] = []
+ let prevRequestsQueries: Array<
+ [readonly unknown[], ConvoRequestListQueryData | undefined]
+ > = []
queryClient.setQueryData(
CONVO_LIST_KEY(status),
(old?: {
@@ -75,11 +83,24 @@ export function useUpdateAllRead(
}
},
)
+ if (status === 'request') {
+ prevRequestsQueries =
+ queryClient.getQueriesData({
+ queryKey: [REQUESTS_RQKEY_ROOT],
+ })
+ queryClient.setQueriesData(
+ {queryKey: [REQUESTS_RQKEY_ROOT]},
+ markAllRequestsRead,
+ )
+ }
onMutate?.()
- return {prevPages}
+ return {prevPages, prevRequestsQueries}
},
onSuccess: () => {
- queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)})
+ void queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)})
+ if (status === 'request') {
+ void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
+ }
onSuccess?.()
},
onError: (error, _, context) => {
@@ -97,8 +118,18 @@ export function useUpdateAllRead(
}
},
)
- queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)})
- queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY('all', 'unread')})
+ if (status === 'request' && context?.prevRequestsQueries) {
+ for (const [queryKey, prevData] of context.prevRequestsQueries) {
+ queryClient.setQueryData(queryKey, prevData)
+ }
+ }
+ void queryClient.invalidateQueries({queryKey: CONVO_LIST_KEY(status)})
+ void queryClient.invalidateQueries({
+ queryKey: CONVO_LIST_KEY('all', 'unread'),
+ })
+ if (status === 'request') {
+ void queryClient.invalidateQueries({queryKey: [REQUESTS_RQKEY_ROOT]})
+ }
onError?.(error)
},
})
diff --git a/src/state/queries/messages/withdraw-join-group-chat.ts b/src/state/queries/messages/withdraw-join-group-chat.ts
index 92025cc2b..2ede8c941 100644
--- a/src/state/queries/messages/withdraw-join-group-chat.ts
+++ b/src/state/queries/messages/withdraw-join-group-chat.ts
@@ -1,9 +1,14 @@
import {type ChatBskyGroupWithdrawJoinRequest} from '@atproto/api'
-import {useMutation} from '@tanstack/react-query'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
+import {
+ type ConvoRequestListQueryData,
+ optimisticDeleteJoinRequest,
+ RQKEY_ROOT as REQUESTS_RQKEY_ROOT,
+} from './list-conversation-requests'
export function useWithdrawJoinGroupChatRequest({
onSuccess,
@@ -13,6 +18,7 @@ export function useWithdrawJoinGroupChatRequest({
onError?: (error: Error) => void
} = {}) {
const agent = useAgent()
+ const queryClient = useQueryClient()
const {hasSession} = useSession()
return useMutation({
@@ -27,7 +33,11 @@ export function useWithdrawJoinGroupChatRequest({
)
return res.data
},
- onSuccess: data => {
+ onSuccess: (data, {convoId}) => {
+ queryClient.setQueriesData(
+ {queryKey: [REQUESTS_RQKEY_ROOT]},
+ old => optimisticDeleteJoinRequest(convoId, old),
+ )
onSuccess?.(data)
},
onError: error => {
--
2.51.2
From 6997d220a02f34f34d311eef71f3eeef3c5f478d Mon Sep 17 00:00:00 2001
From: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Date: Mon, 8 Jun 2026 04:57:08 -0700
Subject: [PATCH 05/20] Add Leave chat button to settings for chat owners
(#10764)
Co-authored-by: Samuel Newman
---
.../Messages/ConversationSettings/index.tsx | 92 ++++++++++++-------
.../Messages/ConversationSettings/prompts.tsx | 24 +++++
.../queries/messages/lock-conversation.ts | 16 +++-
3 files changed, 96 insertions(+), 36 deletions(-)
diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx
index fbc4ffcc6..2ae09b513 100644
--- a/src/screens/Messages/ConversationSettings/index.tsx
+++ b/src/screens/Messages/ConversationSettings/index.tsx
@@ -58,7 +58,12 @@ import {InviteLinkDialog} from '../components/InviteLinkDialog'
import {AddMembersLink} from './AddMembersLink'
import {Member, MemberPlaceholder} from './Member'
import {MembersAndRequests} from './MembersAndRequests'
-import {EditNamePrompt, LeaveChatPrompt, LockChatPrompt} from './prompts'
+import {
+ EditNamePrompt,
+ LeaveAndLockChatPrompt,
+ LeaveChatPrompt,
+ LockChatPrompt,
+} from './prompts'
type Item =
| {type: 'MEMBERS_AND_REQUESTS'; key: string}
@@ -374,33 +379,49 @@ function SettingsHeader({
},
)
- const {mutate: lockConvo, isPending: isLocking} = useLockConvo(
- convo.view.id,
- {
- onSuccess: data => {
- if (!ChatBskyConvoDefs.isGroupConvo(data.convo.kind)) return
- if (data.convo.kind.lockStatus === 'locked') {
- Toast.show(l({message: 'Group chat locked', context: 'toast'}))
- } else {
- Toast.show(l({message: 'Group chat unlocked', context: 'toast'}))
- }
- },
- onError: (e, {lock}) => {
- if (lock) {
- logger.error('Failed to lock group chat', {message: e})
- Toast.show(l`Failed to lock group chat`, {type: 'error'})
- } else {
- logger.error('Failed to unlock group chat', {message: e})
- Toast.show(l`Failed to unlock group chat`, {type: 'error'})
- }
- },
+ const {
+ mutate: lockConvo,
+ mutateAsync: lockConvoAsync,
+ isPending: isLocking,
+ } = useLockConvo(convo.view.id, {
+ onSuccess: (data, {silent}) => {
+ if (!ChatBskyConvoDefs.isGroupConvo(data.convo.kind)) return
+ if (silent) return
+ if (data.convo.kind.lockStatus === 'locked') {
+ Toast.show(l({message: 'Group chat locked', context: 'toast'}))
+ } else {
+ Toast.show(l({message: 'Group chat unlocked', context: 'toast'}))
+ }
},
- )
+ onError: (e, {lock}) => {
+ if (lock) {
+ logger.error('Failed to lock group chat', {message: e})
+ Toast.show(l`Failed to lock group chat`, {type: 'error'})
+ } else {
+ logger.error('Failed to unlock group chat', {message: e})
+ Toast.show(l`Failed to unlock group chat`, {type: 'error'})
+ }
+ },
+ })
+
+ const leaveAndLockConvo = async () => {
+ try {
+ if (lockStatus === 'unlocked') {
+ await lockConvoAsync({lock: true, silent: true})
+ }
+ } catch {
+ // Handled by onError in useLockConvo
+ return
+ }
+ // Owners can only leave a locked chat
+ leaveConvo()
+ }
const inviteLinkDialog = Dialog.useDialogControl()
const editNamePrompt = Prompt.usePromptControl()
const lockChatPrompt = Prompt.usePromptControl()
const leaveChatPrompt = Prompt.usePromptControl()
+ const leaveAndLockChatPrompt = Prompt.usePromptControl()
const reportControl = Prompt.usePromptControl()
const deleteControl = Prompt.usePromptControl()
@@ -452,6 +473,7 @@ function SettingsHeader({
a.justify_center,
a.gap_2xl,
a.pt_2xl,
+ a.flex_wrap,
]}>
) : null}
- {!isOwner ? (
-
- ) : null}
+
+ {
+ void leaveAndLockConvo()
+ }}
+ />
{reportSubjectDid ? (
<>
void
+}) {
+ const {t: l} = useLingui()
+
+ return (
+
+ )
+}
+
export function BlockMemberPrompt({
control,
onConfirm,
diff --git a/src/state/queries/messages/lock-conversation.ts b/src/state/queries/messages/lock-conversation.ts
index b10db1346..122c0633b 100644
--- a/src/state/queries/messages/lock-conversation.ts
+++ b/src/state/queries/messages/lock-conversation.ts
@@ -14,15 +14,21 @@ export function useLockConvo(
onSuccess,
onError,
}: {
- onSuccess?: (data: ChatBskyConvoLockConvo.OutputSchema) => void
- onError?: (error: Error, variables: {lock: boolean}) => void
+ onSuccess?: (
+ data: ChatBskyConvoLockConvo.OutputSchema,
+ variables: {lock: boolean; silent?: boolean},
+ ) => void
+ onError?: (
+ error: Error,
+ variables: {lock: boolean; silent?: boolean},
+ ) => void
},
) {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
- mutationFn: async ({lock}: {lock: boolean}) => {
+ mutationFn: async ({lock}: {lock: boolean; silent?: boolean}) => {
if (!convoId) throw new Error('No convoId provided')
if (lock) {
const {data} = await agent.chat.bsky.convo.lockConvo(
@@ -51,8 +57,8 @@ export function useLockConvo(
}
})
},
- onSuccess: data => {
- onSuccess?.(data)
+ onSuccess: (data, variables) => {
+ onSuccess?.(data, variables)
},
onError: (e, variables, context) => {
if (convoId && context) {
--
2.51.2
From eb79c4d53c1558c22ed7349894ea2f91447ae505 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Mon, 8 Jun 2026 15:10:41 +0300
Subject: [PATCH 06/20] Show accept/reject footer for empty request convos
(#10781)
Co-authored-by: Claude Opus 4.8 (1M context)
---
src/screens/Messages/components/MessagesList.tsx | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx
index 6f56c6a89..727829795 100644
--- a/src/screens/Messages/components/MessagesList.tsx
+++ b/src/screens/Messages/components/MessagesList.tsx
@@ -709,6 +709,10 @@ function getFooterState(
convoState: ActiveConvoStates,
hasAcceptOverride?: boolean,
): FooterState {
+ if (convoState.convo.view.status === 'request' && !hasAcceptOverride) {
+ return 'request'
+ }
+
if (convoState.items.length === 0) {
if (convoState.isFetchingHistory) {
return 'loading'
@@ -717,10 +721,6 @@ function getFooterState(
}
}
- if (convoState.convo.view.status === 'request' && !hasAcceptOverride) {
- return 'request'
- }
-
return 'standard'
}
--
2.51.2
From 16046cd85e0cda11f30376f16bb60418158ba54f Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Mon, 8 Jun 2026 15:27:58 +0300
Subject: [PATCH 07/20] [Chat] Invite link dialog tweaks (#10750)
---
src/screens/Messages/components/InviteLinkDialog.tsx | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/src/screens/Messages/components/InviteLinkDialog.tsx b/src/screens/Messages/components/InviteLinkDialog.tsx
index 6e898a0a9..02d4505af 100644
--- a/src/screens/Messages/components/InviteLinkDialog.tsx
+++ b/src/screens/Messages/components/InviteLinkDialog.tsx
@@ -14,7 +14,7 @@ import {useCreateJoinLink} from '#/state/queries/messages/create-join-link'
import {useDisableJoinLink} from '#/state/queries/messages/disable-join-link'
import {useEditJoinLink} from '#/state/queries/messages/edit-join-link'
import {useEnableJoinLink} from '#/state/queries/messages/enable-join-link'
-import {atoms as a, useTheme, web} from '#/alf'
+import {atoms as a, native, useTheme, web} from '#/alf'
import {
Button,
ButtonIcon,
@@ -304,7 +304,7 @@ export function InviteLinkDialog({
header = linkEnabled ? l`Invite link` : l`Invite link disabled`
content = (
<>
-
+
{
setStep(defaultStep)
setWhoCanJoin(defaultWhoCanJoin)
- }}>
+ }}
+ nativeOptions={{preventExpansion: true}}>
Date: Mon, 8 Jun 2026 05:28:24 -0700
Subject: [PATCH 08/20] Block posting of invalid chat invites (#10757)
Co-authored-by: Samuel Newman
---
src/components/dms/ChatInvite/Root.tsx | 2 +-
src/state/queries/resolve-link.ts | 31 +++++++++++---------------
src/view/com/composer/Composer.tsx | 20 ++++++++++++++++-
3 files changed, 33 insertions(+), 20 deletions(-)
diff --git a/src/components/dms/ChatInvite/Root.tsx b/src/components/dms/ChatInvite/Root.tsx
index a2cfc558e..025cae7a2 100644
--- a/src/components/dms/ChatInvite/Root.tsx
+++ b/src/components/dms/ChatInvite/Root.tsx
@@ -10,7 +10,7 @@ import {type ButtonColor} from '#/components/Button'
import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow'
import {ArrowBoxRight_Stroke2_Corner3_Rounded as JoinIcon} from '#/components/icons/ArrowBoxRight'
import {ChainLink_Stroke2_Corner0_Rounded as LinkIcon} from '#/components/icons/ChainLink'
-import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
+import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {type Props as SVGIconProps} from '#/components/icons/common'
import {RaisingHand4Finger_Stroke2_Corner2_Rounded as HandIcon} from '#/components/icons/RaisingHand'
import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
diff --git a/src/state/queries/resolve-link.ts b/src/state/queries/resolve-link.ts
index a6b21e0bc..2ccd0af86 100644
--- a/src/state/queries/resolve-link.ts
+++ b/src/state/queries/resolve-link.ts
@@ -1,5 +1,5 @@
-import {type BskyAgent} from '@atproto/api'
-import {type QueryClient, useQuery} from '@tanstack/react-query'
+import {type AtpAgent} from '@atproto/api'
+import {type QueryClient, queryOptions, useQuery} from '@tanstack/react-query'
import {type ResolvedLink, resolveGif, resolveLink} from '#/lib/api/resolve'
import {STALE} from '#/state/queries/index'
@@ -12,29 +12,24 @@ export const RQKEY_LINK = (url: string) => [RQKEY_LINK_ROOT, url]
export const RQKEY_GIF_ROOT = 'resolve-gif'
export const RQKEY_GIF = (url: string) => [RQKEY_GIF_ROOT, url]
-export function useResolveLinkQuery(url: string) {
- const agent = useAgent()
-
- return useQuery({
+export function resolveLinkQueryOptions(agent: AtpAgent, url: string) {
+ return queryOptions({
staleTime: STALE.HOURS.ONE,
queryKey: RQKEY_LINK(url),
- queryFn: async () => {
- return await resolveLink(agent, url)
- },
+ queryFn: () => resolveLink(agent, url),
})
}
+
+export function useResolveLinkQuery(url: string) {
+ const agent = useAgent()
+ return useQuery(resolveLinkQueryOptions(agent, url))
+}
export function fetchResolveLinkQuery(
queryClient: QueryClient,
- agent: BskyAgent,
+ agent: AtpAgent,
url: string,
) {
- return queryClient.fetchQuery({
- staleTime: STALE.HOURS.ONE,
- queryKey: RQKEY_LINK(url),
- queryFn: async () => {
- return await resolveLink(agent, url)
- },
- })
+ return queryClient.fetchQuery(resolveLinkQueryOptions(agent, url))
}
export function precacheResolveLinkQuery(
queryClient: QueryClient,
@@ -56,7 +51,7 @@ export function useResolveGifQuery(gif: Gif) {
}
export function fetchResolveGifQuery(
queryClient: QueryClient,
- agent: BskyAgent,
+ agent: AtpAgent,
gif: Gif,
) {
return queryClient.fetchQuery({
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index e130472c7..ab8f4d9cc 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -57,7 +57,7 @@ import {
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
-import {useQueryClient} from '@tanstack/react-query'
+import {useQueries, useQueryClient} from '@tanstack/react-query'
import * as apilib from '#/lib/api/index'
import {EmbeddingDisabledError} from '#/lib/api/resolve'
@@ -95,6 +95,7 @@ import {
} from '#/state/preferences/languages'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useProfileQuery} from '#/state/queries/profile'
+import {resolveLinkQueryOptions} from '#/state/queries/resolve-link'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {type ComposerOpts, type OnPostSuccessData} from '#/state/shell/composer'
@@ -865,8 +866,25 @@ export const ComposePost = ({
}
}, [thread, requireAltTextEnabled, l])
+ // Subscribe to the resolve-link cache for any link URIs in the thread so we
+ // can detect chat invites that resolved to no preview (revoked/expired) and
+ // block publishing - otherwise the post would go out without the embed.
+ const linkUris = thread.posts
+ .filter(post => post.embed.link)
+ .map(post => post.embed.link!.uri)
+ const linkQueries = useQueries({
+ queries: linkUris.map(uri => ({
+ ...resolveLinkQueryOptions(agent, uri),
+ enabled: false,
+ })),
+ })
+ const hasUnavailableChatInvite = linkQueries.some(
+ q => q.data?.type === 'chat-invite' && !q.data.view,
+ )
+
const canPost =
!missingAltError &&
+ !hasUnavailableChatInvite &&
thread.posts.some(post => !isEmptyPost(post)) &&
thread.posts.every(
post =>
--
2.51.2
From d9345cb1cfc54c54123044e0306c014e32b347ae Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Mon, 8 Jun 2026 19:20:37 +0300
Subject: [PATCH 09/20] [Chat] Fix footer logic (#10785)
---
.../Messages/components/MessagesList.tsx | 17 ++++++++++++++++-
.../queries/messages/list-conversations.tsx | 11 ++++++++++-
2 files changed, 26 insertions(+), 2 deletions(-)
diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx
index 727829795..2d471a8d3 100644
--- a/src/screens/Messages/components/MessagesList.tsx
+++ b/src/screens/Messages/components/MessagesList.tsx
@@ -709,7 +709,13 @@ function getFooterState(
convoState: ActiveConvoStates,
hasAcceptOverride?: boolean,
): FooterState {
- if (convoState.convo.view.status === 'request' && !hasAcceptOverride) {
+ const isRequest =
+ convoState.convo.view.status === 'request' && !hasAcceptOverride
+
+ // For group chats, the request footer is driven purely off status: the owner
+ // is always 'accepted' so never sees it, while members the owner added are
+ // 'request' until they accept. This holds even before any messages load.
+ if (convoState.convo.kind === 'group' && isRequest) {
return 'request'
}
@@ -721,6 +727,15 @@ function getFooterState(
}
}
+ // For direct chats, only show the request footer once there's a message. The
+ // viewer's status stays 'request' until they send their first message, so an
+ // empty direct request is one the viewer started themselves (show the
+ // composer), whereas any message present must be an incoming one from the
+ // other user (show the accept/reject footer).
+ if (isRequest) {
+ return 'request'
+ }
+
return 'standard'
}
diff --git a/src/state/queries/messages/list-conversations.tsx b/src/state/queries/messages/list-conversations.tsx
index 372ef708f..62f5772be 100644
--- a/src/state/queries/messages/list-conversations.tsx
+++ b/src/state/queries/messages/list-conversations.tsx
@@ -738,7 +738,16 @@ function calculateCount(
moderateProfile(convo.primaryMember, moderationOpts).blocked ||
convo.primaryMember.handle === 'missing.invalid' ||
(convo.kind === 'group' && convo.details.lockStatus !== 'unlocked')
- const unreadCount = !shouldIgnore && convo.view.unreadCount > 0 ? 1 : 0
+ const unreadJoinRequestCount =
+ convo.kind === 'group'
+ ? (convo.details.unreadJoinRequestCount ?? 0)
+ : 0
+
+ const unreadCount =
+ !shouldIgnore &&
+ (convo.view.unreadCount > 0 || unreadJoinRequestCount > 0)
+ ? 1
+ : 0
return acc + unreadCount
}, 0) ?? 0
--
2.51.2
From c4f3a2cb89b72b59ad25f2cdad303f5f3d747eb3 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Mon, 8 Jun 2026 19:26:23 +0300
Subject: [PATCH 10/20] [Chat] Rename invite route to /chat/ (#10782)
---
__tests__/lib/strings/url-helpers.test.ts | 40 +++++++++----------
bskyweb/cmd/bskyweb/server.go | 6 +--
src/Navigation.tsx | 2 +-
src/components/Post/Embed/ChatInviteEmbed.tsx | 2 +-
src/components/dms/ChatInvite/Root.tsx | 2 +-
src/lib/strings/url-helpers.ts | 2 +-
.../Messages/components/InviteLinkDialog.tsx | 2 +-
7 files changed, 28 insertions(+), 28 deletions(-)
diff --git a/__tests__/lib/strings/url-helpers.test.ts b/__tests__/lib/strings/url-helpers.test.ts
index 23ffaa287..dd1a096ef 100644
--- a/__tests__/lib/strings/url-helpers.test.ts
+++ b/__tests__/lib/strings/url-helpers.test.ts
@@ -184,39 +184,39 @@ describe('getChatInviteCodeFromUrl', () => {
type Case = [string, string | undefined]
const cases: Case[] = [
- ['https://bsky.app/c/abcdefg', 'abcdefg'],
- ['https://bsky.app/c/abcdefghij', 'abcdefghij'],
+ ['https://bsky.app/chat/abcdefg', 'abcdefg'],
+ ['https://bsky.app/chat/abcdefghij', 'abcdefghij'],
// http is not recognized as a bsky.app url
- ['http://bsky.app/c/abcdefg', undefined],
- ['https://bsky.app/c/abcdefg?utm=foo', 'abcdefg'],
- ['https://bsky.app/c/abcdefg#section', 'abcdefg'],
- ['/c/abcdefg', 'abcdefg'],
- ['/c/abcdefg?utm=foo', 'abcdefg'],
- ['/c/abcdefg#section', 'abcdefg'],
+ ['http://bsky.app/chat/abcdefg', undefined],
+ ['https://bsky.app/chat/abcdefg?utm=foo', 'abcdefg'],
+ ['https://bsky.app/chat/abcdefg#section', 'abcdefg'],
+ ['/chat/abcdefg', 'abcdefg'],
+ ['/chat/abcdefg?utm=foo', 'abcdefg'],
+ ['/chat/abcdefg#section', 'abcdefg'],
// too short
- ['https://bsky.app/c/abcdef', undefined],
- ['/c/abcdef', undefined],
+ ['https://bsky.app/chat/abcdef', undefined],
+ ['/chat/abcdef', undefined],
// too long
- ['https://bsky.app/c/abcdefghijk', undefined],
- ['/c/abcdefghijk', undefined],
+ ['https://bsky.app/chat/abcdefghijk', undefined],
+ ['/chat/abcdefghijk', undefined],
// invalid characters
- ['https://bsky.app/c/abc-def', undefined],
- ['/c/abc def', undefined],
+ ['https://bsky.app/chat/abc-def', undefined],
+ ['/chat/abc def', undefined],
// trailing path
- ['https://bsky.app/c/abcdefg/extra', undefined],
- ['/c/abcdefg/extra', undefined],
+ ['https://bsky.app/chat/abcdefg/extra', undefined],
+ ['/chat/abcdefg/extra', undefined],
// wrong path
['https://bsky.app/profile/abcdefg', undefined],
- ['https://bsky.app/c', undefined],
+ ['https://bsky.app/chat', undefined],
// wrong host
- ['https://example.com/c/abcdefg', undefined],
+ ['https://example.com/chat/abcdefg', undefined],
// not a url, not a path
- ['c/abcdefg', undefined],
+ ['chat/abcdefg', undefined],
['abcdefg', undefined],
['', undefined],
// malformed url
- ['https://[invalid/c/abcdefg', undefined],
+ ['https://[invalid/chat/abcdefg', undefined],
]
it.each(cases)('given input %p, returns %p', (input, expected) => {
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index cc5119e8d..c4f31f20b 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -88,7 +88,7 @@ func serve(cctx *cli.Context) error {
Host: appviewHost,
}
- // optional client for the chat appview, used by /c/ for OG previews.
+ // optional client for the chat appview, used by /chat/ for OG previews.
var chatXrpcc *xrpc.Client
if chatHost != "" {
chatXrpcc = &xrpc.Client{
@@ -367,7 +367,7 @@ func serve(cctx *cli.Context) error {
e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack)
// chat invites
- e.GET("/c/:code", server.WebChatInvite)
+ e.GET("/chat/:code", server.WebChatInvite)
// bookmarks
e.GET("/saved", server.WebGeneric)
@@ -695,7 +695,7 @@ func (srv *Server) WebChatInvite(c echo.Context) error {
data["title"] = preview.Name
if srv.cfg.ogcardHost != "" {
- // bskyogcard registers this route as /chat-invite/:code, not /c/:code.
+ // bskyogcard registers this route as /chat-invite/:code, not /chat/:code.
data["imgThumbUrl"] = fmt.Sprintf("%s/chat-invite/%s", srv.cfg.ogcardHost, code)
}
return c.Render(http.StatusOK, "chatinvite.html", data)
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index efd112529..ceebcf398 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -804,7 +804,7 @@ const LINKING = {
return buildStateObject('Flat', 'Home', params)
}
- // Chat invite URLs (`/c/:code`) are handled by `useIntentHandler`, which
+ // Chat invite URLs (`/chat/:code`) are handled by `useIntentHandler`, which
// opens the GroupChatJoinDialog (or the logged-out join flow). Route the
// path to Home so the dialog overlays Home instead of NotFound. On native,
// react-navigation strips the `bluesky://` prefix and passes the path
diff --git a/src/components/Post/Embed/ChatInviteEmbed.tsx b/src/components/Post/Embed/ChatInviteEmbed.tsx
index 387c3cfd9..16f05cbfe 100644
--- a/src/components/Post/Embed/ChatInviteEmbed.tsx
+++ b/src/components/Post/Embed/ChatInviteEmbed.tsx
@@ -8,7 +8,7 @@ import {JoinRequestEmbedBody} from '#/components/Post/Embed/JoinRequestEmbed'
/**
* Renders a chat invite link found in an `app.bsky.embed.external` embed (e.g.
- * a `bsky.app/c/` link posted to the feed) as a join request card,
+ * a `bsky.app/chat/` link posted to the feed) as a join request card,
* falling back to a plain external embed if the invite can't be resolved.
*/
export function ChatInviteEmbed({
diff --git a/src/components/dms/ChatInvite/Root.tsx b/src/components/dms/ChatInvite/Root.tsx
index 025cae7a2..ddfdec0d5 100644
--- a/src/components/dms/ChatInvite/Root.tsx
+++ b/src/components/dms/ChatInvite/Root.tsx
@@ -78,7 +78,7 @@ export function Root({
color: 'primary',
disabled: false,
onPress: () => {
- void setStringAsync(`https://bsky.app/c/${preview.code}`)
+ void setStringAsync(`https://bsky.app/chat/${preview.code}`)
Toast.show(l`Copied to clipboard`, {type: 'success'})
},
}
diff --git a/src/lib/strings/url-helpers.ts b/src/lib/strings/url-helpers.ts
index fd1a5ef7d..38d3d7081 100644
--- a/src/lib/strings/url-helpers.ts
+++ b/src/lib/strings/url-helpers.ts
@@ -179,7 +179,7 @@ export function isBskyStarterPackUrl(url: string): boolean {
}
// Invite codes are 7 alphanumeric characters long, supporting up to 10 here to future-proof.
-export const CHAT_INVITE_CODE_REGEX = /^\/c\/([a-zA-Z0-9]{7,10})$/
+export const CHAT_INVITE_CODE_REGEX = /^\/chat\/([a-zA-Z0-9]{7,10})$/
export function getChatInviteCodeFromUrl(url: string): string | undefined {
let pathname: string
diff --git a/src/screens/Messages/components/InviteLinkDialog.tsx b/src/screens/Messages/components/InviteLinkDialog.tsx
index 02d4505af..636e2be64 100644
--- a/src/screens/Messages/components/InviteLinkDialog.tsx
+++ b/src/screens/Messages/components/InviteLinkDialog.tsx
@@ -292,7 +292,7 @@ export function InviteLinkDialog({
const linkEnabled = joinLink?.enabledStatus === 'enabled'
const linkDisabled = joinLink?.enabledStatus === 'disabled'
const joinLinkURI = joinLink?.code
- ? `https://bsky.app/c/${joinLink.code}`
+ ? `https://bsky.app/chat/${joinLink.code}`
: 'https://bsky.app/'
const createdAt = joinLink ? new Date(joinLink.createdAt) : null
const currentOption =
--
2.51.2
From 0090285fc0e8ee8f4a722749b3e217428fdab1d7 Mon Sep 17 00:00:00 2001
From: Spence Pope
Date: Mon, 8 Jun 2026 12:44:59 -0400
Subject: [PATCH 11/20] Refactor photo embed analytics to post:photoEmbed:*
namespace (#10784)
Co-authored-by: Eric Bailey
Co-authored-by: Claude Opus 4.8 (1M context)
---
src/analytics/metrics/types.ts | 29 +++++++--
src/components/Lightbox/pager/ImagePager.tsx | 20 ++++++-
src/components/Lightbox/state.tsx | 10 ++++
src/components/Post/Embed/ImageEmbed.tsx | 29 ++++++++-
src/components/Post/Embed/index.tsx | 3 +
src/components/Post/Embed/types.ts | 7 +++
src/components/images/Gallery/index.tsx | 20 +++++--
.../components/ThreadItemAnchor.tsx | 2 +
.../PostThread/components/ThreadItemPost.tsx | 1 +
.../components/ThreadItemTreePost.tsx | 1 +
src/view/com/post/Post.tsx | 1 +
src/view/com/posts/PostFeed.tsx | 60 +++++++++++++++----
src/view/com/posts/PostFeedItem.tsx | 5 ++
13 files changed, 163 insertions(+), 25 deletions(-)
diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
index 90dbc4f98..5157c66e3 100644
--- a/src/analytics/metrics/types.ts
+++ b/src/analytics/metrics/types.ts
@@ -1175,18 +1175,37 @@ export type Events = {
'profile:associated:germ:self-disconnect': {}
'profile:associated:germ:self-reconnect': {}
- // Gallery carousel events
- 'post:gallery:swipe': {
+ // Post photo embed events
+ 'post:photoEmbed:impression': {
+ layout: 'single' | 'grid' | 'carousel'
+ totalImages: number
+ postUri: string
+ postAuthorDid: string
+ feedDescriptor?: string
+ }
+ 'post:photoEmbed:open': {
+ layout: 'single' | 'grid' | 'carousel'
fromImage: number
- toImage: number
totalImages: number
+ postUri: string
+ postAuthorDid: string
+ feedDescriptor?: string
}
- 'post:gallery:openLightbox': {
+ 'post:photoEmbed:carouselSwipe': {
fromImage: number
+ toImage: number
totalImages: number
+ postUri: string
+ postAuthorDid: string
+ feedDescriptor?: string
}
- 'post:gallery:impression': {
+ 'post:photoEmbed:lightboxSwipe': {
+ layout: 'single' | 'grid' | 'carousel'
+ fromImage: number
+ toImage: number
totalImages: number
postUri: string
+ postAuthorDid: string
+ feedDescriptor?: string
}
}
diff --git a/src/components/Lightbox/pager/ImagePager.tsx b/src/components/Lightbox/pager/ImagePager.tsx
index 7b8815f27..43942b0f3 100644
--- a/src/components/Lightbox/pager/ImagePager.tsx
+++ b/src/components/Lightbox/pager/ImagePager.tsx
@@ -39,6 +39,7 @@ import {type Dimensions} from '#/lib/media/types'
import {useTheme} from '#/alf'
import {setSystemUITheme} from '#/alf/util/systemUI'
import {type Lightbox} from '#/components/Lightbox/state'
+import {useAnalytics} from '#/analytics'
import {IS_IOS} from '#/env'
import {PlatformInfo} from '../../../../modules/expo-bluesky-swiss-army'
import {Footer} from '../chrome/Footer'
@@ -228,7 +229,8 @@ function ImageView({
openProgress: SharedValue
thumbRects: SharedValue>
}) {
- const {images, index: initialImageIndex} = lightbox
+ const {images, index: initialImageIndex, metricsContext} = lightbox
+ const ax = useAnalytics()
const isAnimated = useMemo(() => canAnimate(lightbox), [lightbox])
const [isScaled, setIsScaled] = useState(false)
const [isDragging, setIsDragging] = useState(false)
@@ -377,7 +379,21 @@ function ImageView({
scrollEnabled={!isScaled}
initialPage={initialImageIndex}
onPageSelected={e => {
- setImageIndex(e.nativeEvent.position)
+ const next = e.nativeEvent.position
+ setImageIndex(prev => {
+ if (metricsContext && prev !== next) {
+ ax.metric('post:photoEmbed:lightboxSwipe', {
+ layout: metricsContext.layout,
+ fromImage: prev + 1,
+ toImage: next + 1,
+ totalImages: images.length,
+ postUri: metricsContext.postUri,
+ postAuthorDid: metricsContext.postAuthorDid,
+ feedDescriptor: metricsContext.feedDescriptor,
+ })
+ }
+ return next
+ })
setIsScaled(false)
}}
onPageScrollStateChanged={e => {
diff --git a/src/components/Lightbox/state.tsx b/src/components/Lightbox/state.tsx
index 23af52773..6a8fdbe0b 100644
--- a/src/components/Lightbox/state.tsx
+++ b/src/components/Lightbox/state.tsx
@@ -11,10 +11,20 @@ import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useHotkeysContext} from '#/lib/hotkeys'
import {type ImageSource} from '#/components/Lightbox/types'
+export type LightboxMetricsContext = {
+ layout: 'single' | 'grid' | 'carousel'
+ postUri: string
+ postAuthorDid: string
+ feedDescriptor?: string
+}
+
export type Lightbox = {
id: string
images: ImageSource[]
index: number
+ // Set for post photo embeds so the lightbox can emit post:photoEmbed:lightboxSwipe.
+ // Left unset for non-post contexts (e.g. profile avatar/banner lightbox).
+ metricsContext?: LightboxMetricsContext
}
const LightboxContext = createContext<{
diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx
index 0a620efde..9d4730206 100644
--- a/src/components/Post/Embed/ImageEmbed.tsx
+++ b/src/components/Post/Embed/ImageEmbed.tsx
@@ -8,7 +8,10 @@ import {atoms as a, tokens} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {Gallery} from '#/components/images/Gallery'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
-import {useLightboxControls} from '#/components/Lightbox/state'
+import {
+ type LightboxMetricsContext,
+ useLightboxControls,
+} from '#/components/Lightbox/state'
import {type Dimensions} from '#/components/Lightbox/types'
import {ImageContextMenu} from '#/components/Post/Embed/ImageContextMenu'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
@@ -40,6 +43,20 @@ export function ImageEmbed({
? images.length > MAX_GRID_IMAGES
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
+ const layout: 'single' | 'grid' | 'carousel' =
+ images.length === 1 ? 'single' : useExpandedLayout ? 'carousel' : 'grid'
+
+ const postContext = rest.post
+ ? {
+ postUri: rest.post.uri,
+ postAuthorDid: rest.post.author.did,
+ feedDescriptor: rest.feedDescriptor,
+ }
+ : undefined
+ const metricsContext: LightboxMetricsContext | undefined = postContext
+ ? {layout, ...postContext}
+ : undefined
+
// Captured from AutoSizedImage so the peek-commit handler can reuse the same
// ref + dims that a tap would — keeps the lightbox's return animation intact.
const singleContainerRef = useRef | null>(null)
@@ -57,6 +74,14 @@ export function ImageEmbed({
refs: AnimatedRef[],
fetchedDims: (Dimensions | null)[],
) => {
+ if (postContext) {
+ ax.metric('post:photoEmbed:open', {
+ layout,
+ fromImage: index + 1,
+ totalImages: images.length,
+ ...postContext,
+ })
+ }
openLightbox({
images: items.map((item, i) => ({
...item,
@@ -67,6 +92,7 @@ export function ImageEmbed({
type: 'image',
})),
index,
+ metricsContext,
})
}
const onPressIn = (_: number) => {
@@ -132,6 +158,7 @@ export function ImageEmbed({
onPressIn={onPressIn}
viewContext={rest.viewContext}
isWithinQuote={rest.isWithinQuote}
+ metricsPostContext={postContext}
/>
)
diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx
index 28eed2aaa..69d92983e 100644
--- a/src/components/Post/Embed/index.tsx
+++ b/src/components/Post/Embed/index.tsx
@@ -345,6 +345,9 @@ export function QuoteEmbed({
allowNestedQuotes={
parentIsWithinQuote ? false : parentAllowNestedQuotes
}
+ // The photo embed belongs to the quoted post, so attribute its
+ // analytics to the quoted post rather than the parent.
+ post={quote}
/>
)}
>
diff --git a/src/components/Post/Embed/types.ts b/src/components/Post/Embed/types.ts
index 6c023a14e..77319b0e8 100644
--- a/src/components/Post/Embed/types.ts
+++ b/src/components/Post/Embed/types.ts
@@ -15,6 +15,13 @@ export type CommonProps = {
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
allowNestedQuotes?: boolean
+ /**
+ * The post that contains this embed. Used for analytics on photo embed
+ * events (post:photoEmbed:*). When the embed has no owning post (e.g.
+ * composer previews), leave this undefined and no events will be emitted.
+ */
+ post?: AppBskyFeedDefs.PostView
+ feedDescriptor?: string
}
export type EmbedProps = CommonProps & {
diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx
index 0ca46c0b6..1b4e1d623 100644
--- a/src/components/images/Gallery/index.tsx
+++ b/src/components/images/Gallery/index.tsx
@@ -55,6 +55,13 @@ interface GalleryProps {
onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
+ // Post context for the in-feed carousel swipe metric. Omit for non-post
+ // contexts (no event will be emitted).
+ metricsPostContext?: {
+ postUri: string
+ postAuthorDid: string
+ feedDescriptor?: string
+ }
}
const Context = createContext<{
@@ -99,6 +106,7 @@ export function Gallery({
onPressIn,
viewContext,
isWithinQuote,
+ metricsPostContext,
}: GalleryProps) {
const {t: l} = useLingui()
const ax = useAnalytics()
@@ -169,13 +177,17 @@ export function Gallery({
const emitSwipeMetric = useMemo(
() =>
debounce((fromIndex: number, toIndex: number) => {
- ax.metric('post:gallery:swipe', {
+ if (!metricsPostContext) return
+ ax.metric('post:photoEmbed:carouselSwipe', {
fromImage: fromIndex + 1, // convert to 1-based index for easier analysis
toImage: toIndex + 1, // convert to 1-based index for easier analysis
totalImages: images.length,
+ postUri: metricsPostContext.postUri,
+ postAuthorDid: metricsPostContext.postAuthorDid,
+ feedDescriptor: metricsPostContext.feedDescriptor,
})
}, 200),
- [ax, images.length],
+ [ax, images.length, metricsPostContext],
)
const setCurrentIndex = (index: number) => {
@@ -277,10 +289,6 @@ export function Gallery({
renderItem={({item, index}) => {
const openLightboxAtIndex = onPress
? () => {
- ax.metric('post:gallery:openLightbox', {
- fromImage: index + 1, // convert to 1-based index for easier analysis
- totalImages: images.length,
- })
const refs: AnimatedRef[] = []
const dims: (Dimensions | null)[] = []
for (let i = 0; i < images.length; i++) {
diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx
index 17c8e54be..dc3c55f12 100644
--- a/src/screens/PostThread/components/ThreadItemAnchor.tsx
+++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx
@@ -414,6 +414,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
moderation={moderation}
viewContext={PostEmbedViewContext.ThreadHighlighted}
onOpen={onOpenEmbed}
+ post={post}
+ feedDescriptor={feedFeedback.feedDescriptor}
/>
)}
diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx
index 841c2af74..c1e27222f 100644
--- a/src/screens/PostThread/components/ThreadItemPost.tsx
+++ b/src/screens/PostThread/components/ThreadItemPost.tsx
@@ -349,6 +349,7 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
+ post={post}
/>
)}
diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx
index 6ee116d16..986878664 100644
--- a/src/screens/PostThread/components/ThreadItemTreePost.tsx
+++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx
@@ -371,6 +371,7 @@ const ThreadItemTreePostInner = memo(function ThreadItemTreePostInner({
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
+ post={post}
/>
)}
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index 62e7cb2aa..c95222455 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -255,6 +255,7 @@ function PostInner({
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
+ post={post}
/>
) : null}
diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx
index 48754d275..20c6a31a3 100644
--- a/src/view/com/posts/PostFeed.tsx
+++ b/src/view/com/posts/PostFeed.tsx
@@ -13,6 +13,8 @@ import {
import {
type AppBskyActorDefs,
AppBskyEmbedExternal,
+ AppBskyEmbedGallery,
+ AppBskyEmbedImages,
AppBskyEmbedVideo,
type AppBskyFeedDefs,
} from '@atproto/api'
@@ -907,7 +909,9 @@ let PostFeed = ({
const seenActorWithStatusRef = useRef>(new Set())
const seenPostUrisRef = useRef>(new Set())
- const seenStandardSiteUrisRef = useRef>(new Set())
+ // Tracks every post we've seen so we can fire per-post events exactly once,
+ // regardless of the post's position within its slice.
+ const seenPerPostUrisRef = useRef>(new Set())
// Helper to calculate position in feed (count only root posts, not interstitials or thread replies)
const getPostPosition = useNonReactiveCallback(
@@ -940,6 +944,48 @@ let PostFeed = ({
(item: FeedRow) => {
feedFeedback.onItemSeen(item)
+ // Events that should fire exactly once for every new post, regardless of
+ // its position within a slice or video grid row.
+ const onPostSeen = (post: AppBskyFeedDefs.PostView) => {
+ if (seenPerPostUrisRef.current.has(post.uri)) return
+ seenPerPostUrisRef.current.add(post.uri)
+
+ // Standard site embed view tracking
+ if (
+ AppBskyEmbedExternal.isView(post.embed) &&
+ isStandardSiteEmbed(post.embed.external)
+ ) {
+ ax.metric('embed:standardSite:view', {url: post.embed.external.uri})
+ }
+
+ // Photo embed impression tracking
+ if (
+ AppBskyEmbedImages.isView(post.embed) ||
+ AppBskyEmbedGallery.isView(post.embed)
+ ) {
+ const totalImages = AppBskyEmbedGallery.isView(post.embed)
+ ? post.embed.items.filter(AppBskyEmbedGallery.isViewImage).length
+ : post.embed.images.length
+ const useExpandedLayout = AppBskyEmbedGallery.isView(post.embed)
+ ? totalImages > 4
+ : ax.features.enabled(ax.features.PostGalleryEmbedEnable)
+ const layout =
+ totalImages === 1
+ ? 'single'
+ : useExpandedLayout
+ ? 'carousel'
+ : 'grid'
+
+ ax.metric('post:photoEmbed:impression', {
+ layout,
+ totalImages,
+ postUri: post.uri,
+ postAuthorDid: post.author.did,
+ feedDescriptor: feedFeedback.feedDescriptor || feed,
+ })
+ }
+ }
+
// Track post:view events
if (item.type === 'sliceItem') {
const slice = item.slice
@@ -947,6 +993,8 @@ let PostFeed = ({
const postItem = slice.items[indexInSlice]
const post = postItem.post
+ onPostSeen(post)
+
// Only track the root post of each slice (index 0) to avoid double-counting thread items
if (indexInSlice === 0 && !seenPostUrisRef.current.has(post.uri)) {
seenPostUrisRef.current.add(post.uri)
@@ -977,16 +1025,6 @@ let PostFeed = ({
})
}
}
-
- // Standard site embed view tracking
- if (
- AppBskyEmbedExternal.isView(post.embed) &&
- isStandardSiteEmbed(post.embed.external) &&
- !seenStandardSiteUrisRef.current.has(post.embed.external.uri)
- ) {
- seenStandardSiteUrisRef.current.add(post.embed.external.uri)
- ax.metric('embed:standardSite:view', {url: post.embed.external.uri})
- }
} else if (item.type === 'videoGridRow') {
// Track each video in the grid row
for (let i = 0; i < item.items.length; i++) {
diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx
index 748d14fac..c0ff26206 100644
--- a/src/view/com/posts/PostFeedItem.tsx
+++ b/src/view/com/posts/PostFeedItem.tsx
@@ -429,6 +429,7 @@ let FeedItemInner = ({
onOpenEmbed={onOpenEmbed}
post={post}
additionalPostAlerts={additionalPostAlerts}
+ feedDescriptor={feedDescriptor}
/>
void
post: AppBskyFeedDefs.PostView
additionalPostAlerts?: AppModerationCause[]
+ feedDescriptor?: string
}): React.ReactNode => {
const [limitLines, setLimitLines] = useState(
() => countLines(richText.text) >= MAX_POST_LINES,
@@ -528,6 +531,8 @@ let PostContent = ({
moderation={moderation}
onOpen={onOpenEmbed}
viewContext={PostEmbedViewContext.Feed}
+ post={post}
+ feedDescriptor={feedDescriptor}
/>
) : null}
--
2.51.2
From 9b5fc19613ce92ca391f00ddb75513ba98907783 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Mon, 8 Jun 2026 20:07:35 +0300
Subject: [PATCH 12/20] [Chat] Centralize conversation report-subject
resolution (#10754)
Co-authored-by: Claude Opus 4.8 (1M context)
---
src/components/dms/ConvoMenu.tsx | 122 ++++++++----------
src/components/dms/MessagesListHeader.tsx | 17 +--
src/components/dms/util.ts | 39 ++++++
.../Messages/components/ChatListItem.tsx | 14 +-
.../Messages/components/ChatStatusInfo.tsx | 12 +-
.../components/IncomingRequestListItem.tsx | 2 +-
.../Messages/components/RequestButtons.tsx | 93 ++++++-------
7 files changed, 151 insertions(+), 148 deletions(-)
diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx
index 1e492c80d..2eac8cdb3 100644
--- a/src/components/dms/ConvoMenu.tsx
+++ b/src/components/dms/ConvoMenu.tsx
@@ -1,6 +1,6 @@
import {memo, useCallback} from 'react'
import {Keyboard, View} from 'react-native'
-import {ChatBskyConvoDefs, type ModerationCause} from '@atproto/api'
+import {type ModerationCause} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
@@ -16,13 +16,18 @@ import {
unstableCacheProfileView,
useProfileBlockMutationQueue,
} from '#/state/queries/profile'
+import {useSession} from '#/state/session'
import {type ViewStyleProp} from '#/alf'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
+import {AfterReportConversationDialog} from '#/components/dms/AfterReportConversationDialog'
import {AfterReportDialog} from '#/components/dms/AfterReportDialog'
import {BlockedByListDialog} from '#/components/dms/BlockedByListDialog'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
-import {ReportConversationDialog} from '#/components/dms/ReportConversationDialog'
+import {
+ type ConvoWithDetails,
+ getConvoReportSubject,
+} from '#/components/dms/util'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid'
@@ -39,7 +44,6 @@ import {ReportDialog} from '#/components/moderation/ReportDialog'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import type * as bsky from '#/types/bsky'
-import {AfterReportConversationDialog} from './AfterReportConversationDialog'
let ConvoMenu = ({
convo,
@@ -49,10 +53,9 @@ let ConvoMenu = ({
showMarkAsRead,
hideTrigger,
blockInfo,
- latestReportableMessage,
style,
}: {
- convo: ChatBskyConvoDefs.ConvoView
+ convo: ConvoWithDetails
profile: Shadow
control?: Menu.MenuControlProps
currentScreen: 'list' | 'conversation'
@@ -62,20 +65,21 @@ let ConvoMenu = ({
listBlocks: ModerationCause[]
userBlock?: ModerationCause
}
- latestReportableMessage?: ChatBskyConvoDefs.MessageView
style?: ViewStyleProp['style']
}): React.ReactNode => {
const {t: l} = useLingui()
const queryClient = useQueryClient()
+ const {currentAccount} = useSession()
const leaveConvoControl = Prompt.usePromptControl()
const reportControl = Prompt.usePromptControl()
const blockedByListControl = Prompt.usePromptControl()
- const blockOrDeleteControl = Prompt.usePromptControl()
- const deleteControl = Prompt.usePromptControl()
+ const afterReportControl = Prompt.usePromptControl()
const {listBlocks} = blockInfo
+ const reportSubject = getConvoReportSubject(convo, currentAccount?.did)
+
return (
<>
@@ -108,6 +112,7 @@ let ConvoMenu = ({
showMarkAsRead={showMarkAsRead}
blockInfo={blockInfo}
convo={convo}
+ canReport={!!reportSubject}
leaveConvoControl={leaveConvoControl}
reportControl={reportControl}
blockedByListControl={blockedByListControl}
@@ -116,54 +121,37 @@ let ConvoMenu = ({
- {latestReportableMessage ? (
- <>
- {
- const sender = convo.members.find(
- member => member.did === latestReportableMessage.sender.did,
- )
- if (sender) {
- unstableCacheProfileView(queryClient, sender)
- }
- blockOrDeleteControl.open()
- }}
- />
-
- >
+ {reportSubject && (
+ {
+ unstableCacheProfileView(queryClient, profile)
+ afterReportControl.open()
+ }}
+ />
+ )}
+ {convo.kind === 'group' ? (
+
) : (
- <>
-
-
- >
+
)}
+ canReport: boolean
showMarkAsRead?: boolean
blockInfo: {
listBlocks: ModerationCause[]
@@ -201,9 +191,9 @@ function MenuContent({
const {listBlocks, userBlock} = blockInfo
const isBlocking = userBlock || !!listBlocks.length
const isDeletedAccount = profile.handle === 'missing.invalid'
- const isGroupConvo = ChatBskyConvoDefs.isGroupConvo(initialConvo.kind)
+ const isGroupConvo = initialConvo.kind === 'group'
- const convoId = initialConvo.id
+ const convoId = initialConvo.view.id
const {data: convo} = useConvoQuery({convoId})
const onNavigateToProfile = useCallback(() => {
@@ -299,15 +289,17 @@ function MenuContent({
)}
-
-
-
- Report conversation
-
-
+ {canReport && (
+
+
+
+ Report conversation
+
+
+ )}
diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx
index f23e7cdfa..745adc62b 100644
--- a/src/components/dms/MessagesListHeader.tsx
+++ b/src/components/dms/MessagesListHeader.tsx
@@ -1,10 +1,6 @@
import {useMemo} from 'react'
import {View} from 'react-native'
-import {
- ChatBskyConvoDefs,
- moderateProfile,
- type ModerationOpts,
-} from '@atproto/api'
+import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {useLingui} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
@@ -12,7 +8,6 @@ import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
-import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {useIsWithinSplitView} from '#/screens/Messages/components/splitView/context'
import {atoms as a, useTheme, web} from '#/alf'
@@ -88,7 +83,6 @@ function ProfileHeaderReady({
}) {
const t = useTheme()
const {t: l} = useLingui()
- const {currentAccount} = useSession()
const profile = useProfileShadow(convo.primaryMember)
const moderation = moderateProfile(profile, moderationOpts)
@@ -110,12 +104,6 @@ function ProfileHeaderReady({
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
const handle = isDeletedAccount ? null : sanitizeHandle(profile.handle, '@')
- const latestReportableMessage =
- ChatBskyConvoDefs.isMessageView(convo.view.lastMessage) &&
- convo.view.lastMessage.sender?.did !== currentAccount?.did
- ? convo.view.lastMessage
- : undefined
-
return (
}
/>
diff --git a/src/components/dms/util.ts b/src/components/dms/util.ts
index 7fe5f91dc..d10b2296f 100644
--- a/src/components/dms/util.ts
+++ b/src/components/dms/util.ts
@@ -10,6 +10,7 @@ import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/profile-shadow'
import {type ConvoState, ConvoStatus} from '#/state/messages/convo/types'
+import {type ReportSubject} from '#/components/moderation/ReportDialog/types'
import * as bsky from '#/types/bsky'
export const MESSAGE_GAP_THRESHOLD_MS = 60 * 60 * 1000
@@ -240,3 +241,41 @@ export function parseConvoView(
return null
}
}
+
+/**
+ * Resolves the report subject for a conversation-level "Report conversation"
+ * action (as opposed to reporting an individual message, which always reports
+ * that message + its sender).
+ *
+ * - group: always report the whole convo, targeting the owner. Returns null if
+ * the owner has left, in which case there is nothing to report against.
+ * - direct: report the last reportable message if there is one (i.e. the last
+ * message exists and wasn't sent by us), otherwise report the whole convo
+ * targeting the other user.
+ */
+export function getConvoReportSubject(
+ convo: ConvoWithDetails,
+ ownDid: string | undefined,
+): ReportSubject | null {
+ if (convo.kind === 'group') {
+ if (!convo.primaryMember) return null
+ return {convoId: convo.view.id, did: convo.primaryMember.did}
+ }
+
+ const lastMessage = convo.view.lastMessage
+ const reportableMessage =
+ ChatBskyConvoDefs.isMessageView(lastMessage) &&
+ lastMessage.sender?.did !== ownDid
+ ? lastMessage
+ : null
+
+ if (reportableMessage) {
+ return {
+ view: 'convo',
+ convoId: convo.view.id,
+ message: reportableMessage,
+ }
+ }
+
+ return {convoId: convo.view.id, did: convo.primaryMember.did}
+}
diff --git a/src/screens/Messages/components/ChatListItem.tsx b/src/screens/Messages/components/ChatListItem.tsx
index 70390606f..1c83a6876 100644
--- a/src/screens/Messages/components/ChatListItem.tsx
+++ b/src/screens/Messages/components/ChatListItem.tsx
@@ -307,20 +307,13 @@ function BaseChatItem({
isDeletedAccount ||
(convo.kind === 'group' && convo.details.lockStatus !== 'unlocked')
- const {
- lastMessage,
- LastMessageIcon,
- lastMessageSentAt,
- latestReportableMessage,
- } = useMemo(() => {
+ const {lastMessage, LastMessageIcon, lastMessageSentAt} = useMemo(() => {
let lastMessage = l`No messages yet`
let LastMessageIcon: React.ComponentType | null = null
let lastMessageSentAt: string | null = null
- let latestReportableMessage: ChatBskyConvoDefs.MessageView | undefined
-
// Deleted message
if (ChatBskyConvoDefs.isDeletedMessageView(convo.view.lastMessage)) {
lastMessageSentAt = convo.view.lastMessage.sentAt
@@ -340,7 +333,6 @@ function BaseChatItem({
if (info) {
lastMessage = info.message ?? lastMessage
lastMessageSentAt = info.sentAt
- latestReportableMessage = info.reportableMessage
}
}
@@ -385,7 +377,6 @@ function BaseChatItem({
lastMessage,
LastMessageIcon,
lastMessageSentAt,
- latestReportableMessage,
}
}, [l, convo, currentAccount?.did, isDeletedAccount, i18n])
@@ -663,7 +654,7 @@ function BaseChatItem({
{/* TODO: Allow showing menu for groups where the owner has left! */}
{showMenu && primaryProfile && (
)}
diff --git a/src/screens/Messages/components/ChatStatusInfo.tsx b/src/screens/Messages/components/ChatStatusInfo.tsx
index ecb0139e6..e2e9a75fa 100644
--- a/src/screens/Messages/components/ChatStatusInfo.tsx
+++ b/src/screens/Messages/components/ChatStatusInfo.tsx
@@ -1,7 +1,7 @@
import {useCallback, useMemo} from 'react'
import {View} from 'react-native'
import {LinearGradient} from 'expo-linear-gradient'
-import {ChatBskyConvoDefs, moderateProfile} from '@atproto/api'
+import {moderateProfile} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
@@ -33,12 +33,6 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
// if we ever allow someone other than the owner to invite people, this will need to change
const otherUser = convoState.convo.primaryMember
- const lastMessage = ChatBskyConvoDefs.isMessageView(
- convoState.convo.view.lastMessage,
- )
- ? convoState.convo.view.lastMessage
- : null
-
if (!moderationOpts) {
return null
}
@@ -64,9 +58,9 @@ export function ChatStatusInfo({convoState}: {convoState: ActiveConvoStates}) {
{otherUser && (
) : null}
& {
label?: string
icon?: boolean
- convo: ChatBskyConvoDefs.ConvoView
+ convo: ConvoWithDetails
profile: ChatBskyActorDefs.ProfileViewBasic
showDeleteConvo?: boolean
currentScreen: 'list' | 'conversation'
}) {
const {t: l} = useLingui()
+ const {currentAccount} = useSession()
const shadowedProfile = useProfileShadow(profile)
const navigation = useNavigation()
const queryClient = useQueryClient()
- const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
+ const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, {
onMutate: () => {
if (currentScreen === 'conversation') {
navigation.dispatch(StackActions.pop())
@@ -110,9 +117,7 @@ export function RejectMenu({
const reportControl = useDialogControl()
const blockOrDeleteControl = useDialogControl()
- const lastMessage = ChatBskyConvoDefs.isMessageView(convo.lastMessage)
- ? convo.lastMessage
- : null
+ const reportSubject = getConvoReportSubject(convo, currentAccount?.did)
return (
<>
@@ -152,50 +157,46 @@ export function RejectMenu({
- {/* note: last message will almost certainly be defined, since you can't
- delete messages for other people and it's impossible for a convo on this
- screen to have a message sent by you */}
- {lastMessage && (
-
-
- Report conversation
-
-
-
- )}
+
+
+ Report conversation
+
+
+
- {lastMessage && (
- <>
- {
- const sender = convo.members.find(
- member => member.did === lastMessage.sender.did,
- )
- if (sender) {
- unstableCacheProfileView(queryClient, sender)
- }
- blockOrDeleteControl.open()
- }}
- />
-
- >
+
+ {reportSubject && (
+ {
+ unstableCacheProfileView(queryClient, profile)
+ blockOrDeleteControl.open()
+ }}
+ />
+ )}
+ {convo.kind === 'group' ? (
+
+ ) : (
+
)}
>
)
--
2.51.2
From 4142eb4834302280fc9a60751a4a3394e8f64220 Mon Sep 17 00:00:00 2001
From: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Date: Mon, 8 Jun 2026 12:13:43 -0700
Subject: [PATCH 13/20] Add fade-in animation to chat footer/composer (#10786)
---
.../Messages/components/MessagesList.tsx | 69 ++++++++++---------
1 file changed, 36 insertions(+), 33 deletions(-)
diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx
index 2d471a8d3..e96872a64 100644
--- a/src/screens/Messages/components/MessagesList.tsx
+++ b/src/screens/Messages/components/MessagesList.tsx
@@ -13,6 +13,7 @@ import {
KeyboardGestureArea,
} from 'react-native-keyboard-controller'
import Animated, {
+ FadeIn,
runOnJS,
type ScrollEvent,
type SharedValue,
@@ -615,41 +616,43 @@ export function MessagesList({
opened: 0,
}}>
{footer ?? (
-
- {({loading}) =>
- ax.features.enabled(
- ax.features.DmsNewMessageComposerEnable,
- ) ? (
-
- void onSendMessage(message)
- }
- hasEmbed={!!messageEmbed}
- setEmbed={setEmbed}
- loading={loading}>
-
+
+ {({loading}) =>
+ ax.features.enabled(
+ ax.features.DmsNewMessageComposerEnable,
+ ) ? (
+
+ void onSendMessage(message)
+ }
+ hasEmbed={!!messageEmbed}
setEmbed={setEmbed}
- />
-
- ) : (
-
-
+
+
+ ) : (
+
-
- )
- }
-
+ loading={loading}>
+
+
+ )
+ }
+
+
)}
--
2.51.2
From fe9dabbc4a9183f0e747e37f991dbebe52b40594 Mon Sep 17 00:00:00 2001
From: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Date: Mon, 8 Jun 2026 12:14:24 -0700
Subject: [PATCH 14/20] Open edit chat name dialog via tap (#10790)
---
.../Messages/ConversationSettings/index.tsx | 34 +++++++++++++------
1 file changed, 23 insertions(+), 11 deletions(-)
diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx
index 2ae09b513..b4c7e1ce0 100644
--- a/src/screens/Messages/ConversationSettings/index.tsx
+++ b/src/screens/Messages/ConversationSettings/index.tsx
@@ -1,5 +1,5 @@
import {useState} from 'react'
-import {View} from 'react-native'
+import {Pressable, View} from 'react-native'
import {
ChatBskyActorDefs,
ChatBskyConvoDefs,
@@ -8,6 +8,7 @@ import {
import {Trans, useLingui} from '@lingui/react/macro'
import {useNavigation} from '@react-navigation/native'
+import {HITSLOP_10} from '#/lib/constants'
import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {
@@ -429,6 +430,13 @@ function SettingsHeader({
const canLockGroupChat = isOwner && lockStatus !== 'locked-permanently'
+ const groupNameComponent = (
+
+ {groupName}
+
+ )
+
return (
<>
-
- {groupName}
-
+ {isOwner ? (
+ {
+ setNewGroupName(groupName)
+ editNamePrompt.open()
+ }}>
+ {groupNameComponent}
+
+ ) : (
+ groupNameComponent
+ )}
Date: Mon, 8 Jun 2026 12:14:51 -0700
Subject: [PATCH 15/20] Remove TODO for uninviting group chat members (#10789)
---
.../ConversationSettings/MemberMenu.tsx | 21 +++----------------
1 file changed, 3 insertions(+), 18 deletions(-)
diff --git a/src/screens/Messages/ConversationSettings/MemberMenu.tsx b/src/screens/Messages/ConversationSettings/MemberMenu.tsx
index c13835a85..9101864d9 100644
--- a/src/screens/Messages/ConversationSettings/MemberMenu.tsx
+++ b/src/screens/Messages/ConversationSettings/MemberMenu.tsx
@@ -38,7 +38,7 @@ export function MemberMenu({
}: {
convo: ConvoWithDetails
profile: Shadow
- type: 'owner' | 'standard' | 'invited'
+ type: 'owner' | 'standard'
displayName: string
isOwner: boolean
}) {
@@ -128,10 +128,7 @@ export function MemberMenu({
}
const canBlockMember = type === 'owner' || type === 'standard'
- const canRemoveMember = isOwner && type !== 'invited'
- // TODO Need to integrate this. -dsb
- const canUninviteMember = false
- // const canUninviteMember = isOwner && type === 'invited'
+ const canRemoveMember = isOwner
return (
<>
@@ -147,7 +144,7 @@ export function MemberMenu({
props.onPress()
},
}
- return type === 'owner' || type === 'invited' ? (
+ return type === 'owner' ? (
) : null}
- {canUninviteMember ? (
- {}}>
-
-
- Uninvite
-
-
- ) : null}
--
2.51.2
From 1c5b3dd3a152e262a1bc2c751f7b2273fbc13653 Mon Sep 17 00:00:00 2001
From: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Date: Mon, 8 Jun 2026 12:15:13 -0700
Subject: [PATCH 16/20] Only show Message option if chat member can be messaged
(#10788)
---
.../ConversationSettings/MemberMenu.tsx | 21 +++++++++++--------
1 file changed, 12 insertions(+), 9 deletions(-)
diff --git a/src/screens/Messages/ConversationSettings/MemberMenu.tsx b/src/screens/Messages/ConversationSettings/MemberMenu.tsx
index 9101864d9..aee0cbf54 100644
--- a/src/screens/Messages/ConversationSettings/MemberMenu.tsx
+++ b/src/screens/Messages/ConversationSettings/MemberMenu.tsx
@@ -12,7 +12,7 @@ import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-memb
import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group'
import {useProfileBlockMutationQueue} from '#/state/queries/profile'
import {atoms as a, useTheme} from '#/alf'
-import {type ConvoWithDetails} from '#/components/dms/util'
+import {canBeMessaged, type ConvoWithDetails} from '#/components/dms/util'
import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft'
import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
@@ -127,6 +127,7 @@ export function MemberMenu({
}
}
+ const canMessageMember = canBeMessaged(profile)
const canBlockMember = type === 'owner' || type === 'standard'
const canRemoveMember = isOwner
@@ -188,14 +189,16 @@ export function MemberMenu({
Go to profile
-
-
-
- Message
-
-
+ {canMessageMember ? (
+
+
+
+ Message
+
+
+ ) : null}
--
2.51.2
From 38ee14d517d572e9cf16261041fd9362b8b2f625 Mon Sep 17 00:00:00 2001
From: Samuel Newman
Date: Mon, 8 Jun 2026 23:14:29 +0300
Subject: [PATCH 17/20] Remove social proof from group invites (#10787)
---
src/screens/Messages/components/IncomingRequestListItem.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/screens/Messages/components/IncomingRequestListItem.tsx b/src/screens/Messages/components/IncomingRequestListItem.tsx
index 8c630b4a0..aa4618188 100644
--- a/src/screens/Messages/components/IncomingRequestListItem.tsx
+++ b/src/screens/Messages/components/IncomingRequestListItem.tsx
@@ -34,7 +34,7 @@ export function IncomingRequestListItem({
return (
- {convo.primaryMember && (
+ {convo.kind === 'direct' && convo.primaryMember && (
Date: Mon, 8 Jun 2026 23:14:43 +0300
Subject: [PATCH 18/20] [Chat] Try and resolve loading bug once and for all
(#10791)
---
.../Messages/components/MessagesList.tsx | 51 ++++++++++---------
1 file changed, 27 insertions(+), 24 deletions(-)
diff --git a/src/screens/Messages/components/MessagesList.tsx b/src/screens/Messages/components/MessagesList.tsx
index e96872a64..599b92833 100644
--- a/src/screens/Messages/components/MessagesList.tsx
+++ b/src/screens/Messages/components/MessagesList.tsx
@@ -204,15 +204,39 @@ export function MessagesList({
// Tracks whether the initial scroll-to-bottom has been triggered. Separated from isAtBottom so that contentInset
// (which causes an early onScroll with negative offset) can't prevent the first scroll.
// Reset when hasScrolled goes back to false (e.g. convo re-initialization after backgrounding).
+ // `didInitialScroll` is the reactive mirror of the ref so the reveal effect below can depend on it; the ref
+ // itself stays as the synchronous re-entry guard inside onContentSizeChange.
const hasInitiallyScrolled = useRef(false)
+ const [didInitialScroll, setDidInitialScroll] = useState(false)
const prevHasScrolled = useRef(hasScrolled)
useLayoutEffect(() => {
if (prevHasScrolled.current && !hasScrolled) {
hasInitiallyScrolled.current = false
+ setDidInitialScroll(false)
}
prevHasScrolled.current = hasScrolled
}, [hasScrolled])
+ // Reveal the list once history has finished loading. We can't reveal earlier because the list isn't inverted -
+ // we must scroll to the bottom (newest message) before fading in, or the user sees a flash of top-anchored content.
+ // This is purely state-driven so it doesn't depend on a layout callback firing: a firehose-delivered message can
+ // dedupe against the fetched history and produce no content-size change, in which case nothing would otherwise
+ // reveal the list and it would stay hidden forever (APP-2238). Either the initial scroll has run, or there's
+ // nothing to scroll (empty convo) - both are safe to reveal once !isFetchingHistory.
+ useEffect(() => {
+ if (hasScrolled || convoState.isFetchingHistory) return
+ if (didInitialScroll || renderItems.length === 0) {
+ const raf = requestAnimationFrame(() => setHasScrolled(true))
+ return () => cancelAnimationFrame(raf)
+ }
+ }, [
+ convoState.isFetchingHistory,
+ hasScrolled,
+ didInitialScroll,
+ renderItems.length,
+ setHasScrolled,
+ ])
+
// -- Keep track of background state and positioning for new pill
const layoutHeight = useSharedValue(0)
const didBackground = useRef(false)
@@ -247,20 +271,15 @@ export function MessagesList({
// Initial scroll to bottom — unconditional, not gated on isAtBottom. This is separated because contentInset
// can cause an early onScroll with a negative offset that sets isAtBottom to false before we get here.
- // Empty convos take this path too (once history is done) so hasScrolled gets set without an animated scroll.
+ // Empty convos take this path too (once history is done). Revealing the list is handled by the effect above,
+ // which fires once history finishes - we just record that the scroll has happened.
if (
!hasInitiallyScrolled.current &&
(renderItems.length > 0 || !convoState.isFetchingHistory)
) {
hasInitiallyScrolled.current = true
+ setDidInitialScroll(true)
flatListRef.current?.scrollToOffset({offset: height, animated: false})
- // If history is already done loading, mark ready after a frame for the scroll to settle.
- // Otherwise, the footer sentinel's onLayout will handle it when history finishes.
- if (!convoState.isFetchingHistory) {
- requestAnimationFrame(() => {
- setHasScrolled(true)
- })
- }
prevContentHeight.current = height
prevItemCount.current = renderItems.length
return
@@ -300,7 +319,6 @@ export function MessagesList({
},
[
hasScrolled,
- setHasScrolled,
convoState.isFetchingHistory,
renderItems.length,
// these are stable
@@ -505,20 +523,6 @@ export function MessagesList({
return null
}
- // Footer sentinel: when history is still loading during the initial scroll, the footer's onLayout fires each time
- // new items are prepended (shifting its position). Once history finishes, this triggers setHasScrolled.
- const onFooterLayout = useCallback(() => {
- if (
- hasInitiallyScrolled.current &&
- !hasScrolled &&
- !convoState.isFetchingHistory
- ) {
- requestAnimationFrame(() => {
- setHasScrolled(true)
- })
- }
- }, [hasScrolled, setHasScrolled, convoState.isFetchingHistory])
-
const renderScrollComponent = useCallback(
(props: ScrollViewProps) => (
@@ -588,7 +592,6 @@ export function MessagesList({
ListFooterComponent={
}
style={[
--
2.51.2
From 690b8184a12fa939c687862e5ccbf00b8dbdce5d Mon Sep 17 00:00:00 2001
From: Eric Bailey
Date: Mon, 8 Jun 2026 15:34:51 -0500
Subject: [PATCH 19/20] Refactor age assurance flags and contexts (#10794)
---
src/ageAssurance/__mocks__/data.tsx | 2 +-
.../components/NoAccessScreen.tsx | 10 +-
src/ageAssurance/data.tsx | 46 ++--
src/ageAssurance/debug.ts | 225 ++++++++++++++++--
src/ageAssurance/index.tsx | 62 ++---
src/ageAssurance/state.ts | 80 ++++---
src/ageAssurance/types.ts | 16 ++
.../useComputeAgeAssuranceRegionAccess.ts | 11 +-
src/ageAssurance/util.ts | 42 +++-
src/state/session/__tests__/session-test.ts | 2 +-
src/state/session/agent.ts | 12 +-
src/state/session/index.tsx | 12 +-
12 files changed, 382 insertions(+), 138 deletions(-)
diff --git a/src/ageAssurance/__mocks__/data.tsx b/src/ageAssurance/__mocks__/data.tsx
index b548a2f86..f813e14cc 100644
--- a/src/ageAssurance/__mocks__/data.tsx
+++ b/src/ageAssurance/__mocks__/data.tsx
@@ -1,3 +1,3 @@
-export const prefetchAgeAssuranceData = () => {}
+export const prefetchAgeAssuranceServerData = () => {}
export const setBirthdateForDid = () => {}
export const setCreatedAtForDid = () => {}
diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx
index 84584b271..ac88e59f7 100644
--- a/src/ageAssurance/components/NoAccessScreen.tsx
+++ b/src/ageAssurance/components/NoAccessScreen.tsx
@@ -32,7 +32,7 @@ import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
import {useAgeAssurance} from '#/ageAssurance'
-import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
+import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import {
isLegacyBirthdateBug,
@@ -53,7 +53,7 @@ export function NoAccessScreen() {
const birthdateControl = useDialogControl()
const deactivateAccountControl = useDialogControl()
const deleteAccountControl = useDialogControl()
- const {data} = useAgeAssuranceDataContext()
+ const {metadata} = useAgeAssuranceServerDataContext()
const region = useAgeAssuranceRegionConfig()
const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed()
const {logoutCurrentAccount} = useSessionApi()
@@ -62,15 +62,15 @@ export function NoAccessScreen() {
const aa = useAgeAssurance()
const isBlocked = aa.state.status === aa.Status.Blocked
const isAARegion = !!region
- const hasDeclaredAge = data?.declaredAge !== undefined
+ const hasDeclaredAge = metadata?.declaredAge !== undefined
const canUpdateBirthday =
- isBirthdateUpdateAllowed || isLegacyBirthdateBug(data?.birthdate || '')
+ isBirthdateUpdateAllowed || isLegacyBirthdateBug(metadata?.birthdate || '')
useEffect(() => {
// just counting overall hits here
ax.metric(`blockedGeoOverlay:shown`, {})
ax.metric(`ageAssurance:noAccessScreen:shown`, {
- accountCreatedAt: data?.accountCreatedAt || 'unknown',
+ accountCreatedAt: metadata?.accountCreatedAt || 'unknown',
isAARegion,
hasDeclaredAge,
canUpdateBirthday,
diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx
index 36a06fbd2..114d94660 100644
--- a/src/ageAssurance/data.tsx
+++ b/src/ageAssurance/data.tsx
@@ -24,6 +24,7 @@ import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declar
import {useAgent, useSession} from '#/state/session'
import * as debug from '#/ageAssurance/debug'
import {logger} from '#/ageAssurance/logger'
+import {type AgeAssuranceMetadata} from '#/ageAssurance/types'
import {
getBirthdateStringFromAge,
isLegacyBirthdateBug,
@@ -485,9 +486,9 @@ export function useOtherRequiredDataQuery() {
}
/**
- * Helper to prefetch all age assurance data.
+ * Helper to prefetch all age assurance data from the server.
*/
-export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
+export function prefetchAgeAssuranceServerData({agent}: {agent: AtpAgent}) {
return Promise.allSettled([
// config fetch initiated at the top of the App.platform.tsx files, awaited here
configPrefetchPromise,
@@ -496,8 +497,8 @@ export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
])
}
-export function clearAgeAssuranceDataForDid({did}: {did: string}) {
- logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
+export function clearAgeAssuranceServerDataForDid({did}: {did: string}) {
+ logger.debug(`clearAgeAssuranceServerDataForDid: ${did}`)
qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
qc.removeQueries({
queryKey: createOtherRequiredDataQueryKey({did}),
@@ -505,8 +506,8 @@ export function clearAgeAssuranceDataForDid({did}: {did: string}) {
})
}
-export function clearAgeAssuranceData() {
- logger.debug(`clearAgeAssuranceData`)
+export function clearAgeAssuranceServerDataForAll() {
+ logger.debug(`clearAgeAssuranceServerDataForAll`)
qc.clear()
}
@@ -514,30 +515,30 @@ export function clearAgeAssuranceData() {
* Context
*/
-export type AgeAssuranceData = {
+export type AgeAssuranceServerData = {
+ /**
+ * The raw config from the appview.
+ */
config: AppBskyAgeassuranceDefs.Config | undefined
+ /**
+ * The raw state from the appview. Must be further processed before being useful.
+ */
state: AppBskyAgeassuranceDefs.State | undefined
- data:
- | {
- accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
- declaredAge: number | undefined
- birthdate: string | undefined
- }
- | undefined
+ metadata: AgeAssuranceMetadata | undefined
}
-export const AgeAssuranceDataContext = createContext({
+const AgeAssuranceServerDataContext = createContext({
config: undefined,
state: undefined,
- data: {
+ metadata: {
accountCreatedAt: undefined,
declaredAge: undefined,
birthdate: undefined,
},
})
-export function useAgeAssuranceDataContext() {
- return useContext(AgeAssuranceDataContext)
+export function useAgeAssuranceServerDataContext() {
+ return useContext(AgeAssuranceServerDataContext)
}
-export function AgeAssuranceDataProvider({
+export function AgeAssuranceServerDataProvider({
children,
}: {
children: React.ReactNode
@@ -550,7 +551,8 @@ export function AgeAssuranceDataProvider({
() => ({
config,
state,
- data: {
+ metadata: {
+ // yes, it's weird, but accountCreatedAt comes back on the `getState` endpoint
accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: data?.birthdate
? getAge(new Date(data.birthdate))
@@ -561,8 +563,8 @@ export function AgeAssuranceDataProvider({
[config, state, data, metadata],
)
return (
-
+
{children}
-
+
)
}
diff --git a/src/ageAssurance/debug.ts b/src/ageAssurance/debug.ts
index 257eeff93..3368ddf14 100644
--- a/src/ageAssurance/debug.ts
+++ b/src/ageAssurance/debug.ts
@@ -26,6 +26,25 @@ export const deviceGeolocation: Geolocation | undefined =
}
: undefined
+export const otherRequiredData: OtherRequiredData = {
+ birthdate: new Date(2010, 12, 1).toISOString(),
+}
+
+const serverStateEnabled = false || IS_E2E
+export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
+ serverStateEnabled
+ ? {
+ state: {
+ lastInitiatedAt: new Date(2025, 1, 1).toISOString(),
+ status: 'assured',
+ access: 'full',
+ },
+ metadata: {
+ accountCreatedAt: new Date(2023, 1, 1).toISOString(),
+ },
+ }
+ : undefined
+
export const config: AppBskyAgeassuranceDefs.Config = {
regions: [
{
@@ -40,37 +59,203 @@ export const config: AppBskyAgeassuranceDefs.Config = {
],
},
{
- countryCode: 'BB',
- regionCode: undefined,
+ countryCode: 'GB',
+ minAccessAge: 13,
+ rules: [
+ {
+ age: 18,
+ access: 'full',
+ $type: ids.IfAssuredOverAge,
+ },
+ {
+ age: 13,
+ access: 'safe',
+ $type: ids.IfDeclaredOverAge,
+ },
+ {
+ access: 'none',
+ $type: ids.Default,
+ },
+ ],
+ },
+ {
+ countryCode: 'AU',
minAccessAge: 16,
rules: [
{
+ date: '2025-12-10T00:00:00Z',
+ access: 'none',
+ $type: ids.IfAccountNewerThan,
+ },
+ {
+ age: 18,
+ access: 'full',
+ $type: ids.IfAssuredOverAge,
+ },
+ {
+ age: 16,
+ access: 'safe',
+ $type: ids.IfAssuredOverAge,
+ },
+ {
+ age: 16,
+ access: 'safe',
+ $type: ids.IfDeclaredOverAge,
+ },
+ {
+ access: 'none',
$type: ids.Default,
+ },
+ ],
+ },
+ {
+ countryCode: 'US',
+ regionCode: 'SD',
+ minAccessAge: 13,
+ rules: [
+ {
+ age: 18,
access: 'full',
+ $type: ids.IfAssuredOverAge,
+ },
+ {
+ age: 13,
+ access: 'safe',
+ $type: ids.IfDeclaredOverAge,
+ },
+ {
+ access: 'none',
+ $type: ids.Default,
},
],
},
- ],
-}
-
-export const otherRequiredData: OtherRequiredData = {
- birthdate: new Date(2000, 1, 1).toISOString(),
-}
-
-const serverStateEnabled = false || IS_E2E
-export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
- serverStateEnabled
- ? {
- state: {
- lastInitiatedAt: new Date(2025, 1, 1).toISOString(),
- status: 'assured',
+ {
+ countryCode: 'US',
+ regionCode: 'WY',
+ minAccessAge: 13,
+ rules: [
+ {
+ age: 18,
access: 'full',
+ $type: ids.IfAssuredOverAge,
},
- metadata: {
- accountCreatedAt: new Date(2023, 1, 1).toISOString(),
+ {
+ age: 13,
+ access: 'safe',
+ $type: ids.IfDeclaredOverAge,
},
- }
- : undefined
+ {
+ access: 'none',
+ $type: ids.Default,
+ },
+ ],
+ },
+ {
+ countryCode: 'US',
+ regionCode: 'OH',
+ minAccessAge: 13,
+ rules: [
+ {
+ age: 18,
+ access: 'full',
+ $type: ids.IfAssuredOverAge,
+ },
+ {
+ age: 13,
+ access: 'safe',
+ $type: ids.IfDeclaredOverAge,
+ },
+ {
+ access: 'none',
+ $type: ids.Default,
+ },
+ ],
+ },
+ {
+ countryCode: 'US',
+ regionCode: 'MS',
+ minAccessAge: 18,
+ rules: [
+ {
+ age: 18,
+ access: 'full',
+ $type: ids.IfAssuredOverAge,
+ },
+ {
+ access: 'none',
+ $type: ids.Default,
+ },
+ ],
+ },
+ {
+ countryCode: 'US',
+ regionCode: 'VA',
+ minAccessAge: 16,
+ rules: [
+ {
+ age: 16,
+ access: 'full',
+ $type: ids.IfAssuredOverAge,
+ },
+ {
+ age: 16,
+ access: 'full',
+ $type: ids.IfDeclaredOverAge,
+ },
+ {
+ access: 'none',
+ $type: ids.Default,
+ },
+ ],
+ },
+ {
+ countryCode: 'US',
+ regionCode: 'TN',
+ minAccessAge: 18,
+ rules: [
+ {
+ age: 18,
+ access: 'full',
+ $type: ids.IfAssuredOverAge,
+ },
+ {
+ age: 18,
+ access: 'full',
+ $type: ids.IfDeclaredOverAge,
+ },
+ {
+ access: 'none',
+ $type: ids.Default,
+ },
+ ],
+ },
+ {
+ countryCode: 'BR',
+ minAccessAge: 13,
+ rules: [
+ {
+ age: 18,
+ access: 'full',
+ $type: ids.IfAssuredOverAge,
+ },
+ {
+ age: 18,
+ access: 'full',
+ $type: ids.IfDeclaredOverAge,
+ },
+ {
+ age: 13,
+ access: 'safe',
+ $type: ids.IfDeclaredOverAge,
+ },
+ {
+ access: 'none',
+ $type: ids.Default,
+ },
+ ],
+ },
+ ],
+}
export async function resolve(data: T) {
await new Promise(y => setTimeout(y, 500)) // simulate network
diff --git a/src/ageAssurance/index.tsx b/src/ageAssurance/index.tsx
index a5cca327a..2b10c93a5 100644
--- a/src/ageAssurance/index.tsx
+++ b/src/ageAssurance/index.tsx
@@ -1,11 +1,11 @@
-import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
+import {createContext, useCallback, useContext, useMemo} from 'react'
import {useGetAndRegisterPushToken} from '#/lib/notifications/notifications'
import {useAgent} from '#/state/session'
import {Provider as RedirectOverlayProvider} from '#/ageAssurance/components/RedirectOverlay'
import {
- AgeAssuranceDataProvider,
- useAgeAssuranceDataContext,
+ AgeAssuranceServerDataProvider,
+ useAgeAssuranceServerDataContext,
} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {
@@ -14,19 +14,19 @@ import {
} from '#/ageAssurance/state'
import {
AgeAssuranceAccess,
+ type AgeAssuranceFlags,
type AgeAssuranceState,
AgeAssuranceStatus,
} from '#/ageAssurance/types'
import {
- isUnderAge,
+ computeAgeAssuranceFlags,
maybeRestrictChatSettings,
- MIN_ACCESS_AGE,
useAgeAssuranceRegionConfigWithFallback,
} from '#/ageAssurance/util'
export {
prefetchConfig as prefetchAgeAssuranceConfig,
- prefetchAgeAssuranceData,
+ prefetchAgeAssuranceServerData,
refetchServerState as refetchAgeAssuranceServerState,
usePatchOtherRequiredData as usePatchAgeAssuranceOtherRequiredData,
usePatchServerState as usePatchAgeAssuranceServerState,
@@ -38,13 +38,7 @@ const AgeAssuranceStateContext = createContext<{
Access: typeof AgeAssuranceAccess
Status: typeof AgeAssuranceStatus
state: AgeAssuranceState
- flags: {
- adultContentDisabled: boolean
- chatDisabled: boolean
- isDeclaredUnderAdultAge: boolean
- isOverRegionMinAccessAge: boolean
- isOverAppMinAccessAge: boolean
- }
+ flags: AgeAssuranceFlags
}>({
Access: AgeAssuranceAccess,
Status: AgeAssuranceStatus,
@@ -73,19 +67,19 @@ export function useAgeAssurance() {
export function Provider({children}: {children: React.ReactNode}) {
return (
-
+
{children}
-
+
)
}
function InnerProvider({children}: {children: React.ReactNode}) {
const agent = useAgent()
const state = useAgeAssuranceState()
- const {data} = useAgeAssuranceDataContext()
- const config = useAgeAssuranceRegionConfigWithFallback()
+ const {metadata} = useAgeAssuranceServerDataContext()
+ const regionConfig = useAgeAssuranceRegionConfigWithFallback()
const getAndRegisterPushToken = useGetAndRegisterPushToken()
const handleAccessUpdate = useCallback(
@@ -100,38 +94,22 @@ function InnerProvider({children}: {children: React.ReactNode}) {
)
useOnAgeAssuranceAccessUpdate(handleAccessUpdate)
- useEffect(() => {
- logger.debug(`useAgeAssuranceState`, {state})
- }, [state])
-
return (
{
- const chatDisabled = state.access !== AgeAssuranceAccess.Full
- const isDeclaredUnderAdultAge = data?.birthdate
- ? isUnderAge(data.birthdate, 18)
- : true
- const isOverRegionMinAccessAge = data?.birthdate
- ? !isUnderAge(data.birthdate, config.minAccessAge)
- : false
- const isOverAppMinAccessAge = data?.birthdate
- ? !isUnderAge(data.birthdate, MIN_ACCESS_AGE)
- : false
- const adultContentDisabled =
- state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
- return {
+ const res = {
Access: AgeAssuranceAccess,
Status: AgeAssuranceStatus,
state,
- flags: {
- adultContentDisabled,
- chatDisabled,
- isDeclaredUnderAdultAge,
- isOverRegionMinAccessAge,
- isOverAppMinAccessAge,
- },
+ flags: computeAgeAssuranceFlags({
+ state,
+ regionConfig,
+ metadata,
+ }),
}
- }, [state, data, config])}>
+ logger.debug(`useAgeAssurance`, res)
+ return res
+ }, [state, metadata, regionConfig])}>
{children}
)
diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts
index 5aac40ef4..ff80bca72 100644
--- a/src/ageAssurance/state.ts
+++ b/src/ageAssurance/state.ts
@@ -1,24 +1,30 @@
import {useEffect, useMemo, useState} from 'react'
-import {computeAgeAssuranceRegionAccess} from '@atproto/api'
+import {
+ type AppBskyAgeassuranceDefs,
+ computeAgeAssuranceRegionAccess,
+} from '@atproto/api'
import {getAge} from '#/lib/strings/time'
import {useSession} from '#/state/session'
import {
- type AgeAssuranceData,
getConfigFromCache,
getOtherRequiredDataFromCache,
getServerStateFromCache,
- useAgeAssuranceDataContext,
+ useAgeAssuranceServerDataContext,
} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {
AgeAssuranceAccess,
+ type AgeAssuranceMetadata,
type AgeAssuranceState,
AgeAssuranceStatus,
parseAccessFromString,
parseStatusFromString,
} from '#/ageAssurance/types'
-import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
+import {
+ computeAgeAssuranceFlags,
+ getAgeAssuranceRegionConfigWithFallback,
+} from '#/ageAssurance/util'
import {type Geolocation, useGeolocation} from '#/geolocation'
import {device} from '#/storage'
@@ -27,18 +33,18 @@ import {device} from '#/storage'
* server state before computing access based on AA config from the server +
* geolocation and other data.
*/
-export function computeAgeAssuranceState({
+function computeAgeAssuranceState({
hasSession,
- config,
geolocation,
+ config,
state,
- data,
+ metadata,
}: {
hasSession: boolean
- config: AgeAssuranceData['config']
geolocation: Geolocation
- state: AgeAssuranceData['state']
- data: AgeAssuranceData['data']
+ config?: AppBskyAgeassuranceDefs.Config
+ state?: AppBskyAgeassuranceDefs.State
+ metadata?: AgeAssuranceMetadata
}) {
/**
* This is where we control logged-out moderation prefs. It's all
@@ -88,7 +94,10 @@ export function computeAgeAssuranceState({
* accounts with an accurate birthdate, our default fallback rules should
* ensure correct access.
*/
- const result = computeAgeAssuranceRegionAccess(region, data)
+ const result = computeAgeAssuranceRegionAccess(region, {
+ accountCreatedAt: metadata?.accountCreatedAt,
+ declaredAge: metadata?.declaredAge,
+ })
const computed = {
lastInitiatedAt: state?.lastInitiatedAt,
// prefer server state
@@ -100,10 +109,10 @@ export function computeAgeAssuranceState({
? parseAccessFromString(result.access)
: AgeAssuranceAccess.Full,
}
- logger.debug('debug useAgeAssuranceState', {
+ logger.debug('computeAgeAssuranceState', {
region,
state,
- data,
+ metadata,
computed,
})
return computed
@@ -113,38 +122,51 @@ export function computeAgeAssuranceState({
* This is a last-ditch helper for out-of-band reads of the AA state, such as
* during account creation. Don't use it for anything else.
*/
-export function getAndComputeAgeAssuranceState({did}: {did: string}) {
+export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
const config = getConfigFromCache()
const state = getServerStateFromCache({did})
- const data = getOtherRequiredDataFromCache({did})
+ const requiredData = getOtherRequiredDataFromCache({did})
const geolocation = device.get(['mergedGeolocation'])
- if (!geolocation || !config || !state || !data) {
+ if (!geolocation || !config || !state || !requiredData) {
return {
- status: AgeAssuranceStatus.Unknown,
- access: AgeAssuranceAccess.Safe,
+ state: {
+ status: AgeAssuranceStatus.Unknown,
+ access: AgeAssuranceAccess.Safe,
+ },
}
}
- return computeAgeAssuranceState({
+ const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
+ const metadata: AgeAssuranceMetadata = {
+ accountCreatedAt: state.metadata?.accountCreatedAt,
+ declaredAge: requiredData?.birthdate
+ ? getAge(new Date(requiredData.birthdate))
+ : undefined,
+ birthdate: requiredData?.birthdate,
+ }
+ const computed = computeAgeAssuranceState({
hasSession: true,
config,
geolocation,
state: state.state,
- data: {
- accountCreatedAt: state.metadata?.accountCreatedAt,
- declaredAge: data?.birthdate
- ? getAge(new Date(data.birthdate))
- : undefined,
- birthdate: data?.birthdate,
- },
+ metadata,
})
+
+ return {
+ state: computed,
+ flags: computeAgeAssuranceFlags({
+ state: computed,
+ regionConfig: region,
+ metadata,
+ }),
+ }
}
export function useAgeAssuranceState(): AgeAssuranceState {
const {hasSession} = useSession()
const geolocation = useGeolocation()
- const {config, state, data} = useAgeAssuranceDataContext()
+ const {config, state, metadata} = useAgeAssuranceServerDataContext()
return useMemo(
() =>
@@ -153,9 +175,9 @@ export function useAgeAssuranceState(): AgeAssuranceState {
config,
geolocation,
state,
- data,
+ metadata,
}),
- [hasSession, geolocation, config, state, data],
+ [hasSession, geolocation, config, state, metadata],
)
}
diff --git a/src/ageAssurance/types.ts b/src/ageAssurance/types.ts
index f34ed10ae..12473bc2e 100644
--- a/src/ageAssurance/types.ts
+++ b/src/ageAssurance/types.ts
@@ -1,3 +1,5 @@
+import {type computeAgeAssuranceRegionAccess} from '@atproto/api'
+
import {logger} from '#/ageAssurance/logger'
export enum AgeAssuranceAccess {
@@ -14,6 +16,12 @@ export enum AgeAssuranceStatus {
Blocked = 'blocked',
}
+export type AgeAssuranceMetadata = Parameters<
+ typeof computeAgeAssuranceRegionAccess
+>[1] & {
+ birthdate: string | undefined
+}
+
export type AgeAssuranceState = {
lastInitiatedAt?: string
status: AgeAssuranceStatus
@@ -21,6 +29,14 @@ export type AgeAssuranceState = {
error?: 'config' // maybe other specific cases in the future
}
+export type AgeAssuranceFlags = {
+ adultContentDisabled: boolean
+ chatDisabled: boolean
+ isDeclaredUnderAdultAge: boolean
+ isOverRegionMinAccessAge: boolean
+ isOverAppMinAccessAge: boolean
+}
+
export function parseStatusFromString(raw: string) {
switch (raw) {
case 'unknown':
diff --git a/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts b/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts
index e3ea48860..5ba9e1ba6 100644
--- a/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts
+++ b/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts
@@ -1,14 +1,14 @@
import {useCallback} from 'react'
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
-import {useAgeAssuranceDataContext} from '#/ageAssurance/data'
+import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
import {AgeAssuranceAccess, parseAccessFromString} from '#/ageAssurance/types'
import {getAgeAssuranceRegionConfigWithFallback} from '#/ageAssurance/util'
import {type Geolocation} from '#/geolocation'
export function useComputeAgeAssuranceRegionAccess() {
- const {config, data} = useAgeAssuranceDataContext()
+ const {config, metadata} = useAgeAssuranceServerDataContext()
return useCallback(
(geolocation: Geolocation) => {
if (!config) {
@@ -19,11 +19,14 @@ export function useComputeAgeAssuranceRegionAccess() {
config,
geolocation,
)
- const result = computeAgeAssuranceRegionAccess(region, data)
+ const result = computeAgeAssuranceRegionAccess(region, {
+ accountCreatedAt: metadata?.accountCreatedAt,
+ declaredAge: metadata?.declaredAge,
+ })
return result
? parseAccessFromString(result.access)
: AgeAssuranceAccess.Full
},
- [config, data],
+ [config, metadata],
)
}
diff --git a/src/ageAssurance/util.ts b/src/ageAssurance/util.ts
index 310725db8..b0e601d6c 100644
--- a/src/ageAssurance/util.ts
+++ b/src/ageAssurance/util.ts
@@ -13,9 +13,14 @@ import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/
import {
getDidFromAgentSession,
getOtherRequiredDataFromCache,
- useAgeAssuranceDataContext,
+ useAgeAssuranceServerDataContext,
} from '#/ageAssurance/data'
-import {AgeAssuranceAccess} from '#/ageAssurance/types'
+import {
+ AgeAssuranceAccess,
+ type AgeAssuranceFlags,
+ type AgeAssuranceMetadata,
+ type AgeAssuranceState,
+} from '#/ageAssurance/types'
import {type Geolocation, useGeolocation} from '#/geolocation'
export const MIN_ACCESS_AGE = 13
@@ -62,7 +67,7 @@ export function getAgeAssuranceRegionConfigWithFallback(
*/
export function useAgeAssuranceRegionConfig() {
const geolocation = useGeolocation()
- const {config} = useAgeAssuranceDataContext()
+ const {config} = useAgeAssuranceServerDataContext()
return useMemo(() => {
if (!config) return
// use generic helper, we want to potentially return undefined
@@ -128,3 +133,34 @@ export function maybeRestrictChatSettings({agent}: {agent: AtpAgent}) {
if (data?.actorDeclaration?.allowIncoming === 'none') return
restrictChatSettings({agent, did})
}
+
+export function computeAgeAssuranceFlags({
+ state,
+ regionConfig,
+ metadata,
+}: {
+ state: AgeAssuranceState
+ regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
+ metadata?: AgeAssuranceMetadata
+}): AgeAssuranceFlags {
+ const chatDisabled = state.access !== AgeAssuranceAccess.Full
+ const isDeclaredUnderAdultAge = metadata?.declaredAge
+ ? metadata.declaredAge < 18
+ : true
+ const isOverRegionMinAccessAge = metadata?.declaredAge
+ ? metadata.declaredAge >= regionConfig.minAccessAge
+ : false
+ const isOverAppMinAccessAge = metadata?.declaredAge
+ ? metadata.declaredAge >= MIN_ACCESS_AGE
+ : false
+ const adultContentDisabled =
+ state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
+
+ return {
+ adultContentDisabled,
+ chatDisabled,
+ isDeclaredUnderAdultAge,
+ isOverRegionMinAccessAge,
+ isOverAppMinAccessAge,
+ }
+}
diff --git a/src/state/session/__tests__/session-test.ts b/src/state/session/__tests__/session-test.ts
index 4b014d644..4398a90a0 100644
--- a/src/state/session/__tests__/session-test.ts
+++ b/src/state/session/__tests__/session-test.ts
@@ -13,7 +13,7 @@ jest.mock('jwt-decode', () => ({
jest.mock('../../birthdate')
jest.mock('../../../ageAssurance/data')
jest.mock('../../../ageAssurance/state', () => ({
- getAndComputeAgeAssuranceState: () => ({}),
+ unsafeGetAndComputeAgeAssurance: () => ({state: {}}),
}))
jest.mock('#/lib/notifications/notifications', () => ({
unregisterPushToken(_agents: BskyAgent[]) {
diff --git a/src/state/session/agent.ts b/src/state/session/agent.ts
index 9f2d70927..0abc0ca6c 100644
--- a/src/state/session/agent.ts
+++ b/src/state/session/agent.ts
@@ -24,11 +24,11 @@ import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
import {restrictChatSettings} from '#/state/queries/messages/restrictChatSettings'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
import {
- prefetchAgeAssuranceData,
+ prefetchAgeAssuranceServerData,
setBirthdateForDid,
setCreatedAtForDid,
} from '#/ageAssurance/data'
-import {getAndComputeAgeAssuranceState} from '#/ageAssurance/state'
+import {unsafeGetAndComputeAgeAssurance} from '#/ageAssurance/state'
import {AgeAssuranceAccess} from '#/ageAssurance/types'
import {features} from '#/analytics'
import {emitNetworkConfirmed, emitNetworkLost} from '../events'
@@ -74,7 +74,7 @@ export async function createAgentAndResume(
}
// after session is attached
- const aa = prefetchAgeAssuranceData({agent})
+ const aa = prefetchAgeAssuranceServerData({agent})
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
@@ -113,7 +113,7 @@ export async function createAgentAndLogin(
const account = agentToSessionAccountOrThrow(agent)
const gates = features.refresh({strategy: 'prefer-fresh-gates'})
const moderation = configureModerationForAccount(agent, account)
- const aa = prefetchAgeAssuranceData({agent})
+ const aa = prefetchAgeAssuranceServerData({agent})
agent.configureProxy(BLUESKY_PROXY_HEADER.get())
@@ -175,7 +175,7 @@ export async function createAgentAndCreateAccount(
setBirthdateForDid({did: account.did, birthdate})
snoozeBirthdateUpdateAllowedForDid(account.did)
// do this last
- const aa = prefetchAgeAssuranceData({agent})
+ const aa = prefetchAgeAssuranceServerData({agent})
// Not awaited so that we can still get into onboarding.
// This is OK because we won't let you toggle adult stuff until you set the date.
@@ -219,7 +219,7 @@ export async function createAgentAndCreateAccount(
}),
// wait for AA data to load first, then check state
aa.then(async () => {
- const state = getAndComputeAgeAssuranceState({did: account.did})
+ const {state} = unsafeGetAndComputeAgeAssurance({did: account.did})
if (state.access !== AgeAssuranceAccess.Full) {
restrictChatSettings({agent, did: account.did})
}
diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx
index 09e7bd0db..fd31261a9 100644
--- a/src/state/session/index.tsx
+++ b/src/state/session/index.tsx
@@ -36,8 +36,8 @@ import {
} from '#/state/session/types'
import {useOnboardingDispatch} from '#/state/shell/onboarding'
import {
- clearAgeAssuranceData,
- clearAgeAssuranceDataForDid,
+ clearAgeAssuranceServerDataForAll,
+ clearAgeAssuranceServerDataForDid,
} from '#/ageAssurance/data'
const StateContext = createContext({
@@ -203,7 +203,9 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
)
addSessionDebugLog({type: 'method:end', method: 'logout'})
if (prevState.currentAgentState.did) {
- clearAgeAssuranceDataForDid({did: prevState.currentAgentState.did})
+ clearAgeAssuranceServerDataForDid({
+ did: prevState.currentAgentState.did,
+ })
void clearPersistedQueryStorage(prevState.currentAgentState.did)
}
// reset onboarding flow on logout
@@ -234,7 +236,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
},
)
addSessionDebugLog({type: 'method:end', method: 'logout'})
- clearAgeAssuranceData()
+ clearAgeAssuranceServerDataForAll()
for (const account of prevState.accounts) {
void clearPersistedQueryStorage(account.did)
}
@@ -304,7 +306,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
accountDid: account.did,
})
addSessionDebugLog({type: 'method:end', method: 'removeAccount', account})
- clearAgeAssuranceDataForDid({did: account.did})
+ clearAgeAssuranceServerDataForDid({did: account.did})
},
[store, cancelPendingTask],
)
--
2.51.2
From bfbd1cef32c312894690e6d59e14cf2fc92ad0c8 Mon Sep 17 00:00:00 2001
From: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Date: Mon, 8 Jun 2026 13:53:09 -0700
Subject: [PATCH 20/20] Upgrade to pnpm 11.5.2 (#10793)
---
Dockerfile.embedr | 2 +-
package.json | 2 +-
pnpm-lock.yaml | 78 +++++++++++++++++++++++------------------------
3 files changed, 41 insertions(+), 41 deletions(-)
diff --git a/Dockerfile.embedr b/Dockerfile.embedr
index a55ddf11a..42c1a725f 100644
--- a/Dockerfile.embedr
+++ b/Dockerfile.embedr
@@ -33,7 +33,7 @@ RUN mkdir --parents $NVM_DIR && \
RUN \. "$NVM_DIR/nvm.sh" && \
nvm install $NODE_VERSION && \
nvm use $NODE_VERSION && \
- npm install --global pnpm@11.5.0 && \
+ npm install --global pnpm@11.5.2 && \
pnpm install --frozen-lockfile && \
cd bskyembed && pnpm install --frozen-lockfile && cd .. && \
pnpm intl:build && \
diff --git a/package.json b/package.json
index a879182d9..e054aca94 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"devEngines": {
"packageManager": {
"name": "pnpm",
- "version": "11.5.0",
+ "version": "11.5.2",
"onFail": "warn"
},
"runtime": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0ac8974cf..b2f65bf40 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,52 +7,52 @@ importers:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
- specifier: 11.5.0
- version: 11.5.0
+ specifier: 11.5.2
+ version: 11.5.2
pnpm:
- specifier: 11.5.0
- version: 11.5.0
+ specifier: 11.5.2
+ version: 11.5.2
packages:
- '@pnpm/exe@11.5.0':
- resolution: {integrity: sha512-4hzOXq1HHrNPjwI8k1rt7Ot/Yrdx1JX3pn/L/M95ii1gid1Q6ZK6dVg4+gbSgUdPsYmYDZ4/Yfc0A7vd5C0ndg==}
+ '@pnpm/exe@11.5.2':
+ resolution: {integrity: sha512-4UFnP2rhNu1xjAQ+I1GdIUUEtCJuTYJlbpiWSFA4POAID3Lpt+2vrjImWO7eOJ7iCY3vpc4TFe2IW3sAolW4Kg==}
hasBin: true
- '@pnpm/linux-arm64@11.5.0':
- resolution: {integrity: sha512-NV9HdzzCB0epuI9LqZZeTaqjH3OweNQSQCS76GzEkFxJHS9e5Gvu7tgex91gxVL7bCZ+R4yr/3d3yexBFtr2ug==}
+ '@pnpm/linux-arm64@11.5.2':
+ resolution: {integrity: sha512-MbJySnu2y9cCBqlODLjUlZ87JnRC3Inq40rvGHWJSrSQ0PnuHeSw2NDMnLI8Hf9hCY+ooussRc5iiR4IAkjUvg==}
cpu: [arm64]
os: [linux]
- '@pnpm/linux-x64@11.5.0':
- resolution: {integrity: sha512-vH83rRx4iPk/bwm9pBVCn+5hXbcQI66I/4zk6Vc09SusJgTqOdbN4U6VhMcGIqSEdr901ksYGCyIbMv7f6Guew==}
+ '@pnpm/linux-x64@11.5.2':
+ resolution: {integrity: sha512-g6g2BGpQA47wUACy6B1MdeSHPtnl6x4AeCg0IOWQ7xXorEtC+VRiSHhLpA5kByFGeSwyYh/nLc7mLul5DAaELw==}
cpu: [x64]
os: [linux]
- '@pnpm/linuxstatic-arm64@11.5.0':
- resolution: {integrity: sha512-2nOnMW1rSwGv22q2yZz1HlGT3ly/Ij8wUlX0NB4n+Krx7nETRHA3MgWsbkVejxHknDcTulRVudAghuX9rgrXcw==}
+ '@pnpm/linuxstatic-arm64@11.5.2':
+ resolution: {integrity: sha512-xTxs9BLxYW39BPNGnmvYCUBnMPWm4mzmzujmdYbpRxDnBXrx55qPR5K/3LSohX7VrmsdDrYxuH6AmG1AaOlIfA==}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@pnpm/linuxstatic-x64@11.5.0':
- resolution: {integrity: sha512-ONOC1Mg0JusHtjzkRlre9di1QO+GAjy4HP7jMjDx21yGhrSheNdUweTXbekMH1EflRd19kTU6d8M3zewJFPtVg==}
+ '@pnpm/linuxstatic-x64@11.5.2':
+ resolution: {integrity: sha512-RGmmc/SoGLD90gmOHcU85UEKNoNRstLvizli4wzDASmETz/VeqJOqU5nD1YBgjzcP72sUMS352dh4bmzTfKyvQ==}
cpu: [x64]
os: [linux]
libc: [musl]
- '@pnpm/macos-arm64@11.5.0':
- resolution: {integrity: sha512-od0ALdTxs4A7s5vAH5q2l2phzCJb98+PVOW1rq7BGpWGeYxQ+EwvL+vq0KaO6iLsn/eVVoncCkgZ/k6QNYuTgw==}
+ '@pnpm/macos-arm64@11.5.2':
+ resolution: {integrity: sha512-gW3A2jRlC3SJRw8qX2SAzjMIu9o98daTSqCKzeeYcjF/uEbtbz3dn4HqYrYffBnenKbc4hsgZQmNOHAvUKIlSg==}
cpu: [arm64]
os: [darwin]
- '@pnpm/win-arm64@11.5.0':
- resolution: {integrity: sha512-9HqbI80FjVVqFx4+EPxYYNfeP9Sx69W6kYqUDvOJn9G7RJ/2NNNQ898cVHTMpXlW1/PrMEcijmdpa/NjZIrWiQ==}
+ '@pnpm/win-arm64@11.5.2':
+ resolution: {integrity: sha512-+VJCDoH/pRzLXBikwjvxgAnGfQufT8EALBX8cfSmrwD40JABUZvgPtjBjde7OwEoK/XwtlH8w+ZceFV0K3/YHQ==}
cpu: [arm64]
os: [win32]
- '@pnpm/win-x64@11.5.0':
- resolution: {integrity: sha512-Q89CQqFGAsWmfvHZs5Kbbar45q3GBYtfAdPUCiVMVNJoLi3dsBS2LCvUq8ak3AufkFDaJBpvhaFcDP2M1NXr3A==}
+ '@pnpm/win-x64@11.5.2':
+ resolution: {integrity: sha512-zgglREh75RbFgV/E0tNRS03ElX+hJOV43KRSSeaboxtj3ei1rrguxOgOCXUs/GsizoHVsuD+qXGABE4Kc4GMCg==}
cpu: [x64]
os: [win32]
@@ -116,45 +116,45 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
- pnpm@11.5.0:
- resolution: {integrity: sha512-2/zE+Bz0hZev1Lw5H/3xLBHxqfuDo5W/prCi2cwv2P/rr9scy9UpYyFT95OQTCYVt/Cf4aNFRz/Rw1hFFyqOsQ==}
+ pnpm@11.5.2:
+ resolution: {integrity: sha512-ccYx44IGbvwlYl1c8CkHXeB7YbN/bic1D72Esb2lhkyMGWetwoB3a0XDCnFcA1mjvgj+9C1bsJ4rmQKZeWkpFg==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
- '@pnpm/exe@11.5.0':
+ '@pnpm/exe@11.5.2':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
- '@pnpm/linux-arm64': 11.5.0
- '@pnpm/linux-x64': 11.5.0
- '@pnpm/linuxstatic-arm64': 11.5.0
- '@pnpm/linuxstatic-x64': 11.5.0
- '@pnpm/macos-arm64': 11.5.0
- '@pnpm/win-arm64': 11.5.0
- '@pnpm/win-x64': 11.5.0
-
- '@pnpm/linux-arm64@11.5.0':
+ '@pnpm/linux-arm64': 11.5.2
+ '@pnpm/linux-x64': 11.5.2
+ '@pnpm/linuxstatic-arm64': 11.5.2
+ '@pnpm/linuxstatic-x64': 11.5.2
+ '@pnpm/macos-arm64': 11.5.2
+ '@pnpm/win-arm64': 11.5.2
+ '@pnpm/win-x64': 11.5.2
+
+ '@pnpm/linux-arm64@11.5.2':
optional: true
- '@pnpm/linux-x64@11.5.0':
+ '@pnpm/linux-x64@11.5.2':
optional: true
- '@pnpm/linuxstatic-arm64@11.5.0':
+ '@pnpm/linuxstatic-arm64@11.5.2':
optional: true
- '@pnpm/linuxstatic-x64@11.5.0':
+ '@pnpm/linuxstatic-x64@11.5.2':
optional: true
- '@pnpm/macos-arm64@11.5.0':
+ '@pnpm/macos-arm64@11.5.2':
optional: true
- '@pnpm/win-arm64@11.5.0':
+ '@pnpm/win-arm64@11.5.2':
optional: true
- '@pnpm/win-x64@11.5.0':
+ '@pnpm/win-x64@11.5.2':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
@@ -194,7 +194,7 @@ snapshots:
detect-libc@2.1.2: {}
- pnpm@11.5.0: {}
+ pnpm@11.5.2: {}
---
lockfileVersion: '9.0'