[Chat] Surface blocked members to top of group member list for owner (#10726)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-06-09 20:15:03 +03:00
committed by GitHub
parent 750c55eff8
commit 3fdfebfe29
4 changed files with 73 additions and 3 deletions
@@ -7,18 +7,22 @@ import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-disp
import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useRequireAuth, useSession} from '#/state/session'
import {atoms as a, native, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {
type ConvoWithDetails,
type GroupConvoMember,
} from '#/components/dms/util'
import {createStaticClick, SimpleInlineLinkText} from '#/components/Link'
import * as ProfileCard from '#/components/ProfileCard'
import * as Prompt from '#/components/Prompt'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {MemberMenu} from './MemberMenu'
import {RemoveMemberPrompt} from './prompts'
import {StatusBadge} from './StatusBadge'
import {SubtleHoverWrapper} from './SubtleHoverWrapper'
@@ -45,6 +49,14 @@ export function Member({
const [queueFollow] = useProfileFollowMutationQueue(profile, 'GroupChat')
const requireAuth = useRequireAuth()
const removeMemberPrompt = Prompt.usePromptControl()
const {mutate: removeMembers} = useRemoveFromGroupChat(convo.view.id, {
onError: e => {
logger.error('Failed to remove group chat member', {message: e})
Toast.show(l`Failed to remove group chat member`, {type: 'error'})
},
})
const isFollowing = !!profile.viewer?.following
const handleFollow = () => {
@@ -101,6 +113,9 @@ export function Member({
)}`
: l`Added by invite link`
// Surface a prominent remove button to the owner for blocked members.
const showRemoveButton = isOwner && !isSelf && !!isBlockedOrBlocking(profile)
return (
<SubtleHoverWrapper>
<View style={outerStyles}>
@@ -137,7 +152,17 @@ export function Member({
</ProfileCard.Header>
</ProfileCard.Outer>
</ProfileCard.Link>
{isSelf || isFollowing || isBlockedOrBlocking(profile) ? null : (
{showRemoveButton ? (
<Button
label={l`Remove ${displayName} from this group chat`}
size="tiny"
color="negative_subtle"
onPress={() => removeMemberPrompt.open()}>
<ButtonText>
<Trans>Remove</Trans>
</ButtonText>
</Button>
) : isSelf || isFollowing || isBlockedOrBlocking(profile) ? null : (
<SimpleInlineLinkText
label={l`Follow ${displayName}`}
{...createStaticClick(handleFollow)}
@@ -147,6 +172,14 @@ export function Member({
)}
{statusBadge}
</View>
{/* Mounted outside the showRemoveButton conditional: confirming the
prompt optimistically drops this row, so gating the prompt on the
button would unmount it mid-close and race the dismiss animation. */}
<RemoveMemberPrompt
control={removeMemberPrompt}
displayName={displayName}
onConfirm={() => removeMembers({members: [profile.did]})}
/>
</SubtleHoverWrapper>
)
}
@@ -26,7 +26,7 @@ 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} from './prompts'
import {BlockMemberPrompt, RemoveMemberPrompt} from './prompts'
import {StatusBadge} from './StatusBadge'
export function MemberMenu({
@@ -50,6 +50,7 @@ export function MemberMenu({
const requireEmailVerification = useRequireEmailVerification()
const blockMemberPrompt = Prompt.usePromptControl()
const removeMemberPrompt = Prompt.usePromptControl()
const [menuDidOpen, setMenuDidOpen] = useState(false)
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, {
@@ -227,7 +228,7 @@ export function MemberMenu({
<Menu.Item
destructive
label={l`Remove ${displayName} from this group chat`}
onPress={() => removeMembers({members: [profile.did]})}>
onPress={removeMemberPrompt.open}>
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
<Menu.ItemText>
<Trans>Remove from chat</Trans>
@@ -241,6 +242,11 @@ export function MemberMenu({
control={blockMemberPrompt}
onConfirm={() => void handleBlockMember()}
/>
<RemoveMemberPrompt
control={removeMemberPrompt}
displayName={displayName}
onConfirm={() => removeMembers({members: [profile.did]})}
/>
</>
)
}
@@ -11,6 +11,7 @@ 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 {isBlockedOrBlocking} from '#/lib/moderation/blocked-and-muted'
import {
type CommonNavigatorParams,
type NativeStackScreenProps,
@@ -214,6 +215,12 @@ function GroupSettings({
const bIsSelf = b.did === currentAccount?.did
if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1
if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1
// Surface blocked members to the owner so they can be removed.
if (isOwner) {
const aBlocked = !!isBlockedOrBlocking(a)
const bBlocked = !!isBlockedOrBlocking(b)
if (aBlocked !== bBlocked) return aBlocked ? -1 : 1
}
return 0
})
@@ -151,6 +151,30 @@ export function LeaveAndLockChatPrompt({
)
}
export function RemoveMemberPrompt({
control,
displayName,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
displayName: string
onConfirm: () => void
}) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Remove ${displayName}?`}
description={l`They wont be able to rejoin unless you invite them again.`}
confirmButtonCta={l`Remove`}
confirmButtonColor="negative"
cancelButtonCta={l`Cancel`}
onConfirm={onConfirm}
/>
)
}
export function BlockMemberPrompt({
control,
onConfirm,