From 753015b59f25c1f3e227b18bec21839ef92f3c84 Mon Sep 17 00:00:00 2001 From: surfdude29 <149612116+surfdude29@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:42:50 +0100 Subject: [PATCH 01/20] Add plural formatting to "Group name is too long" strings (#10769) --- src/components/dms/InitiateChatFlow.tsx | 9 ++++++--- src/screens/Messages/ConversationSettings/prompts.tsx | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) 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' -- 2.51.2