From c27f50e038c603b03c1dc727235253f381b1d479 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Tue, 9 Jun 2026 12:18:58 -0700 Subject: [PATCH] Add list of mutual chats to block dialog (#10727) Co-authored-by: Eric Bailey Co-authored-by: Claude Opus 4.8 (1M context) --- eslint-suppressions.json | 17 - .../PostControls/PostMenu/PostMenuItems.tsx | 10 +- src/components/moderation/BlockDialog.tsx | 398 ++++++++++++++++++ .../ConversationSettings/MemberMenu.tsx | 9 +- .../Messages/ConversationSettings/prompts.tsx | 21 - .../queries/messages/list-mutual-groups.ts | 39 ++ src/view/com/profile/ProfileMenu.tsx | 159 +++---- 7 files changed, 512 insertions(+), 141 deletions(-) create mode 100644 src/components/moderation/BlockDialog.tsx create mode 100644 src/state/queries/messages/list-mutual-groups.ts diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 559f1e564d..e1e981fd08 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2356,23 +2356,6 @@ "count": 2 } }, - "src/view/com/profile/ProfileMenu.tsx": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - }, - "@typescript-eslint/no-floating-promises": { - "count": 4 - }, - "@typescript-eslint/no-misused-promises": { - "count": 3 - }, - "@typescript-eslint/no-unsafe-call": { - "count": 6 - }, - "@typescript-eslint/no-unsafe-member-access": { - "count": 12 - } - }, "src/view/com/testing/TestCtrls.e2e.tsx": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index 257c1dcb41..f34c9a473c 100644 --- a/src/components/PostControls/PostMenu/PostMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -88,6 +88,7 @@ import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' import {Loader} from '#/components/Loader' import * as Menu from '#/components/Menu' +import {BlockDialog} from '#/components/moderation/BlockDialog' import { ReportDialog, useReportDialogControl, @@ -845,13 +846,10 @@ let PostMenuItems = ({ onConfirm={() => void onToggleReplyVisibility()} confirmButtonCta={l`Yes, hide`} /> - void onBlockAuthor()} - confirmButtonCta={l`Block`} - confirmButtonColor="negative" + profile={postAuthor} + onBlock={onBlockAuthor} /> ) diff --git a/src/components/moderation/BlockDialog.tsx b/src/components/moderation/BlockDialog.tsx new file mode 100644 index 0000000000..ed20c6f50a --- /dev/null +++ b/src/components/moderation/BlockDialog.tsx @@ -0,0 +1,398 @@ +import {useState} from 'react' +import {View} from 'react-native' +import { + type ChatBskyConvoDefs, + ChatBskyConvoLeaveConvo, + ChatBskyGroupRemoveMembers, +} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' +import {useQueryClient} from '@tanstack/react-query' + +import {isNetworkError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {type Shadow} from '#/state/cache/types' +import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' +import { + createListMutualGroupsQueryKey, + useListMutualGroupsQuery, +} from '#/state/queries/messages/list-mutual-groups' +import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group' +import {useSession} from '#/state/session' +import {atoms as a, native, useTheme, web} from '#/alf' +import {AvatarBubbles} from '#/components/AvatarBubbles' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {type DialogControlProps} from '#/components/Dialog' +import {parseConvoView} from '#/components/dms/util' +import {Loader} from '#/components/Loader' +import * as Toast from '#/components/Toast' +import {Text} from '#/components/Typography' +import {type AnyProfileView} from '#/types/bsky/profile' + +type Item = ChatBskyConvoDefs.ConvoView + +type BlockDialogProps = { + control: DialogControlProps + profile: Shadow + onBlock: () => Promise + currentConvoId?: string +} + +export function BlockDialog({ + control, + profile, + onBlock, + currentConvoId, +}: BlockDialogProps) { + return ( + + + + + + + + ) +} + +function BlockDialogInner({ + control, + profile, + onBlock, + currentConvoId, +}: { + control: DialogControlProps + profile: Shadow + onBlock: () => Promise + currentConvoId?: string +}) { + const t = useTheme() + const {t: l} = useLingui() + + const [headerHeight, setHeaderHeight] = useState(0) + const [footerHeight, setFooterHeight] = useState(0) + + /* + * Optimistically hide convos the viewer has left or removed the profile + * from, before the query refetches. We don't expect many items here, so a + * simple filter is fine. + */ + const [removedConvoIds, setRemovedConvoIds] = useState>( + () => new Set(), + ) + const onOptimisticallyRemoveConvo = (convoId: string) => { + setRemovedConvoIds(prev => { + const next = new Set(prev) + next.add(convoId) + return next + }) + } + const onRestoreConvo = (convoId: string) => { + setRemovedConvoIds(prev => { + const next = new Set(prev) + next.delete(convoId) + return next + }) + } + + const {data, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage} = + useListMutualGroupsQuery({ + subject: profile.did, + enabled: !profile.viewer?.blocking, + }) + const items: Item[] = (data?.pages.flatMap(page => page.convos) ?? []).filter( + item => !removedConvoIds.has(item.id), + ) + const hasMutualGroupChats = items.length > 0 + + const onEndReached = async () => { + if (isFetchingNextPage || !hasNextPage) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more mutual group chats', {message: err}) + } + } + + const renderItems = ({item}: {item: Item}) => { + return ( + + ) + } + + const listHeader = ( + setHeaderHeight(evt.nativeEvent.layout.height)}> + + + {profile.viewer?.blocking ? ( + Unblock account? + ) : ( + Block account? + )} + + + {profile.viewer?.blocking ? ( + + The account will be able to interact with you after unblocking. + + ) : profile.associated?.labeler ? ( + + Blocking will not prevent labels from being applied on your + account, but it will stop this account from replying in your + threads or interacting with you. + + ) : ( + + Blocked accounts cannot reply in your threads, mention you, or + otherwise interact with you. + + )} + + + {hasMutualGroupChats ? ( + + + Mutual group chats + + + ) : null} + + ) + + const footer = ( + + + + + ) + + if (isLoading || !hasMutualGroupChats) { + return ( + + {listHeader} + {isLoading ? ( + + + + ) : null} + {footer} + + ) + } + + return ( + + + + ) : null + } + footer={ + setFooterHeight(evt.nativeEvent.layout.height)}> + {footer} + + } + contentContainerStyle={[a.gap_0, {paddingBottom: footerHeight}]} + scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} + onEndReached={() => void onEndReached()} + onEndReachedThreshold={0.5} + style={[web([{maxWidth: 420}])]} + /> + ) +} + +function MutualGroupChat({ + view, + profileDid, + currentConvoId, + onOptimisticallyRemoveConvo, + onRestoreConvo, +}: { + view: ChatBskyConvoDefs.ConvoView + profileDid: string + currentConvoId?: string + onOptimisticallyRemoveConvo: (convoId: string) => void + onRestoreConvo: (convoId: string) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const {currentAccount} = useSession() + const queryClient = useQueryClient() + + const convo = parseConvoView(view, currentAccount?.did) + + const {mutate: leaveConvo, isPending: isLeavePending} = useLeaveConvo( + convo?.view.id, + { + onSuccess: () => { + Toast.show(l`Left group chat.`) + void queryClient.invalidateQueries({ + queryKey: createListMutualGroupsQueryKey({subject: profileDid}), + }) + }, + onError: error => { + onRestoreConvo(view.id) + logger.error('Error leaving group chat', {message: error}) + let errorMessage = l`Could not leave chat.` + if (isNetworkError(error)) { + errorMessage = l`A network error occurred. Please check your internet connection.` + } else if (error instanceof ChatBskyConvoLeaveConvo.InvalidConvoError) { + errorMessage = l`Chat not found.` + } else if ( + error instanceof ChatBskyConvoLeaveConvo.OwnerCannotLeaveError + ) { + errorMessage = l`Chat owners cannot leave a group chat.` + } + Toast.show(errorMessage, {type: 'error'}) + }, + }, + ) + + const {mutate: removeMembers, isPending: isRemovePending} = + useRemoveFromGroupChat(convo?.view.id, { + onSuccess: () => { + Toast.show(l`Member removed from group chat.`) + void queryClient.invalidateQueries({ + queryKey: createListMutualGroupsQueryKey({subject: profileDid}), + }) + }, + onError: error => { + onRestoreConvo(view.id) + logger.error('Error removing group chat member', {message: error}) + let errorMessage = l`Could not remove member.` + if (isNetworkError(error)) { + errorMessage = l`A network error occurred. Please check your internet connection.` + } else if ( + error instanceof ChatBskyGroupRemoveMembers.InvalidConvoError + ) { + errorMessage = l`Chat not found.` + } else if ( + error instanceof ChatBskyGroupRemoveMembers.InsufficientRoleError + ) { + errorMessage = l`You must be a chat owner to remove a member.` + } + Toast.show(errorMessage, {type: 'error'}) + }, + }) + + if (!convo || convo.kind !== 'group') return null + + const owner = convo.primaryMember + const isViewerOwner = owner?.did != null && owner.did === currentAccount?.did + const isProfileOwner = owner?.did != null && owner.did === profileDid + const isCurrentConvo = view.id === currentConvoId + + return ( + + + + + + {convo.details.name} + + {isViewerOwner ? ( + + You own this chat + + ) : isProfileOwner ? ( + + They own this chat + + ) : null} + + + {isViewerOwner ? ( + + ) : isCurrentConvo ? ( + + Current chat + + ) : ( + + )} + + ) +} diff --git a/src/screens/Messages/ConversationSettings/MemberMenu.tsx b/src/screens/Messages/ConversationSettings/MemberMenu.tsx index 2d36bc115f..60b5b5e2eb 100644 --- a/src/screens/Messages/ConversationSettings/MemberMenu.tsx +++ b/src/screens/Messages/ConversationSettings/MemberMenu.tsx @@ -22,11 +22,12 @@ import { PersonX_Stroke2_Corner0_Rounded as PersonXIcon, } from '#/components/icons/Person' import * as Menu from '#/components/Menu' +import {BlockDialog} from '#/components/moderation/BlockDialog' import * as Prompt from '#/components/Prompt' import * as Toast from '#/components/Toast' import {useAnalytics} from '#/analytics' import type * as bsky from '#/types/bsky' -import {BlockMemberPrompt, RemoveMemberPrompt} from './prompts' +import {RemoveMemberPrompt} from './prompts' import {StatusBadge} from './StatusBadge' export function MemberMenu({ @@ -238,9 +239,11 @@ export function MemberMenu({ - void handleBlockMember()} + profile={profile} + onBlock={handleBlockMember} + currentConvoId={convoId} /> ) } - -export function BlockMemberPrompt({ - control, - onConfirm, -}: { - control: Dialog.DialogOuterProps['control'] - onConfirm: () => void -}) { - const {t: l} = useLingui() - - return ( - - ) -} diff --git a/src/state/queries/messages/list-mutual-groups.ts b/src/state/queries/messages/list-mutual-groups.ts new file mode 100644 index 0000000000..9ff4a1da09 --- /dev/null +++ b/src/state/queries/messages/list-mutual-groups.ts @@ -0,0 +1,39 @@ +import {useInfiniteQuery} from '@tanstack/react-query' + +import {DM_SERVICE_HEADERS} from '#/lib/constants' +import {createQueryKey} from '#/state/queries/util' +import {useAgent} from '#/state/session' + +const listMutualGroupsQueryKeyRoot = 'list-mutual-groups' + +export const createListMutualGroupsQueryKey = (args: {subject: string}) => + createQueryKey(listMutualGroupsQueryKeyRoot, args) + +export function useListMutualGroupsQuery({ + subject, + enabled, + limit = 20, +}: { + subject: string | undefined + enabled?: boolean + limit?: number +}) { + const agent = useAgent() + const isEnabled = enabled !== false && !!subject + + return useInfiniteQuery({ + gcTime: 0, + staleTime: 0, + enabled: isEnabled, + queryKey: createListMutualGroupsQueryKey({subject: subject ?? ''}), + queryFn: async ({pageParam}) => { + const {data} = await agent.chat.bsky.group.listMutualGroups( + {subject: subject!, cursor: pageParam, limit}, + {headers: DM_SERVICE_HEADERS}, + ) + return data + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: page => page.cursor, + }) +} diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 51edee1903..5e6c266ea8 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -1,8 +1,6 @@ import {memo, useCallback, useMemo} from 'react' import {type AppBskyActorDefs} from '@atproto/api' -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 {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' @@ -46,6 +44,7 @@ import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' import {StarterPack} from '#/components/icons/StarterPack' import * as Menu from '#/components/Menu' +import {BlockDialog} from '#/components/moderation/BlockDialog' import { ReportDialog, useReportDialogControl, @@ -72,7 +71,7 @@ let ProfileMenu = ({ }): React.ReactNode => { const t = useTheme() const ax = useAnalytics() - const {_} = useLingui() + const {t: l} = useLingui() const {currentAccount, hasSession} = useSession() const {openModal} = useModalControls() const reportDialogControl = useReportDialogControl() @@ -116,7 +115,7 @@ let ProfileMenu = ({ }, [currentAccount, profile]) const invalidateProfileQuery = useCallback(() => { - queryClient.invalidateQueries({ + void queryClient.invalidateQueries({ queryKey: profileQueryKey(profile.did), }) }, [queryClient, profile.did]) @@ -124,10 +123,10 @@ let ProfileMenu = ({ const onPressAddToStarterPacks = useCallback(() => { ax.metric('profile:addToStarterPack', {}) addToStarterPacksDialogControl.open() - }, [addToStarterPacksDialogControl]) + }, [addToStarterPacksDialogControl, ax]) const onPressShare = useCallback(() => { - shareUrl(toShareUrl(makeProfileLink(profile))) + void shareUrl(toShareUrl(makeProfileLink(profile))) }, [profile]) const onPressAddRemoveLists = useCallback(() => { @@ -145,11 +144,12 @@ let ProfileMenu = ({ if (profile.viewer?.muted) { try { await queueUnmute() - Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) - } catch (e: any) { + Toast.show(l({message: 'Account unmuted', context: 'toast'})) + } catch (err) { + const e = err as Error if (e?.name !== 'AbortError') { ax.logger.error('Failed to unmute account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), { + Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) } @@ -157,27 +157,29 @@ let ProfileMenu = ({ } else { try { await queueMute() - Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) - } catch (e: any) { + Toast.show(l({message: 'Account muted', context: 'toast'})) + } catch (err) { + const e = err as Error if (e?.name !== 'AbortError') { ax.logger.error('Failed to mute account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), { + Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) } } } - }, [ax, profile.viewer?.muted, queueUnmute, _, queueMute]) + }, [ax, profile.viewer?.muted, queueUnmute, l, queueMute]) const blockAccount = useCallback(async () => { if (profile.viewer?.blocking) { try { await queueUnblock() - Toast.show(_(msg({message: 'Account unblocked', context: 'toast'}))) - } catch (e: any) { + Toast.show(l({message: 'Account unblocked', context: 'toast'})) + } catch (err) { + const e = err as Error if (e?.name !== 'AbortError') { ax.logger.error('Failed to unblock account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), { + Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) } @@ -185,56 +187,59 @@ let ProfileMenu = ({ } else { try { await queueBlock() - Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) - } catch (e: any) { + Toast.show(l({message: 'Account blocked', context: 'toast'})) + } catch (err) { + const e = err as Error if (e?.name !== 'AbortError') { ax.logger.error('Failed to block account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), { + Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) } } } - }, [ax, profile.viewer?.blocking, _, queueUnblock, queueBlock]) + }, [ax, profile.viewer?.blocking, l, queueUnblock, queueBlock]) const onPressFollowAccount = useCallback(async () => { try { await queueFollow() - Toast.show(_(msg({message: 'Account followed', context: 'toast'}))) - } catch (e: any) { + Toast.show(l({message: 'Account followed', context: 'toast'})) + } catch (err) { + const e = err as Error if (e?.name !== 'AbortError') { ax.logger.error('Failed to follow account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), { + Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) } } - }, [_, ax, queueFollow]) + }, [l, ax, queueFollow]) const onPressUnfollowAccount = useCallback(async () => { try { await queueUnfollow() - Toast.show(_(msg({message: 'Account unfollowed', context: 'toast'}))) - } catch (e: any) { + Toast.show(l({message: 'Account unfollowed', context: 'toast'})) + } catch (err) { + const e = err as Error if (e?.name !== 'AbortError') { ax.logger.error('Failed to unfollow account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), { + Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) } } - }, [_, ax, queueUnfollow]) + }, [l, ax, queueUnfollow]) const onPressReportAccount = useCallback(() => { reportDialogControl.open() }, [reportDialogControl]) const onPressShareATUri = useCallback(() => { - shareText(`at://${profile.did}`) + void shareText(`at://${profile.did}`) }, [profile.did]) const onPressShareDID = useCallback(() => { - shareText(profile.did) + void shareText(profile.did) }, [profile.did]) const onPressSearch = useCallback(() => { @@ -251,14 +256,14 @@ let ProfileMenu = ({ return ( - + {({props}) => { return ( <> - {statusNudgeActive && } ) @@ -278,9 +282,7 @@ let ProfileMenu = ({ { if (showLoggedOutWarning) { loggedOutWarningPromptControl.open() @@ -301,7 +303,7 @@ let ProfileMenu = ({ Search posts @@ -320,14 +322,12 @@ let ProfileMenu = ({ void onPressUnfollowAccount() + : () => void onPressFollowAccount() }> {isFollowing ? ( @@ -343,7 +343,7 @@ let ProfileMenu = ({ )} Add to starter packs @@ -352,7 +352,7 @@ let ProfileMenu = ({ Add to lists @@ -364,10 +364,10 @@ let ProfileMenu = ({ testID="profileHeaderDropdownListAddRemoveBtn" label={ status.isDisabled - ? _(msg`Go live (disabled)`) + ? l`Go live (disabled)` : status.isActive - ? _(msg`Edit live status`) - : _(msg`Go live`) + ? l`Edit live status` + : l`Go live` } onPress={() => { if (status.isDisabled) { @@ -418,7 +418,7 @@ let ProfileMenu = ({ (verification.viewer.hasIssuedVerification ? ( verificationRemovePromptControl.open()}> Remove verification @@ -428,7 +428,7 @@ let ProfileMenu = ({ ) : ( verificationCreatePromptControl.open()}> Verify account @@ -444,10 +444,10 @@ let ProfileMenu = ({ testID="profileHeaderDropdownMuteBtn" label={ profile.viewer?.muted - ? _(msg`Unmute account`) - : _(msg`Mute account`) + ? l`Unmute account` + : l`Mute account` } - onPress={onPressMuteAccount}> + onPress={() => void onPressMuteAccount()}> {profile.viewer?.muted ? ( Unmute account @@ -464,9 +464,9 @@ let ProfileMenu = ({ blockPromptControl.open()}> @@ -485,7 +485,7 @@ let ProfileMenu = ({ )} Report account @@ -503,7 +503,7 @@ let ProfileMenu = ({ Copy at:// URI @@ -512,7 +512,7 @@ let ProfileMenu = ({ Copy DID @@ -524,12 +524,10 @@ let ProfileMenu = ({ ) : null} - - - - - - - {status.isDisabled ? (