import {useMemo, useState} from 'react' import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native' import {type ChatBskyConvoDefs, moderateProfile} from '@atproto/api' import {plural} from '@lingui/core/macro' import {Trans, useLingui} from '@lingui/react/macro' import {StackActions, useNavigation} from '@react-navigation/native' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification' import { type CommonNavigatorParams, type NativeStackScreenProps, type NavigationProp, } from '#/lib/routes/types' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {logger} from '#/logger' import {type Shadow} from '#/state/cache/types' import {ConvoProvider, useConvo} from '#/state/messages/convo' import {ConvoStatus} from '#/state/messages/convo/types' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability' import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members' import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' import {useMuteConvo} from '#/state/queries/messages/mute-conversation' import {useProfileBlockMutationQueue} from '#/state/queries/profile' import {useSession} from '#/state/session' import {List} from '#/view/com/util/List' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' import {AvatarBubbles} from '#/components/AvatarBubbles' import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {AddMembersFlow} from '#/components/dms/AddMembersFlow' import {Error} from '#/components/Error' import * as TextField from '#/components/forms/TextField' import {useInteractionState} from '#/components/hooks/useInteractionState' import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeftIcon} from '#/components/icons/ArrowBoxLeft' import { Bell2_Stroke2_Corner0_Rounded as BellIcon, Bell2Off_Stroke2_Corner0_Rounded as BellOffIcon, } from '#/components/icons/Bell2' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron' import {type Props as SVGIconProps} from '#/components/icons/common' import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' import {EditBig_Stroke2_Corner0_Rounded as EditIcon} from '#/components/icons/EditBig' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' import {Lock_Stroke2_Corner0_Rounded as LockIcon} from '#/components/icons/Lock' import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message' import { Person_Stroke2_Corner2_Rounded as PersonIcon, PersonX_Stroke2_Corner0_Rounded as PersonXIcon, } from '#/components/icons/Person' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import * as Layout from '#/components/Layout' import {InlineLinkText} from '#/components/Link' import * as Menu from '#/components/Menu' import {type TriggerChildProps} from '#/components/Menu/types' import * as Prompt from '#/components/Prompt' import {SubtleHover} from '#/components/SubtleHover' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {useAnalytics} from '#/analytics' import {IS_NATIVE} from '#/env' import type * as bsky from '#/types/bsky' const MEMBER_LIMIT = 50 const ROW_SPACING = 10 type Item = | { type: 'MEMBERS_AND_REQUESTS' } | { type: 'ADD_MEMBERS_LINK' } | { type: 'CHAT_MEMBER' profile: Shadow status: 'owner' | 'member' | 'invited' } type Props = NativeStackScreenProps< CommonNavigatorParams, 'MessagesConversationSettings' > /** * TODO This is just layout for now. */ export function MessagesConversationSettingsScreen({route}: Props) { const {gtTablet} = useBreakpoints() const convoId = route.params.conversation return ( Group chat settings ) } function keyExtractor(item: Item) { return item.type === 'CHAT_MEMBER' ? item.profile.did : item.type } function SettingsInner() { const {t: l} = useLingui() const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) const bottomBarOffset = useBottomBarOffset() const convoState = useConvo() const {currentAccount} = useSession() const primaryMember = convoState?.getPrimaryMember?.() const data: bsky.profile.AnyProfileView[] = convoState.convo?.members ?? [] const invites: string[] = [] const items = [ { type: 'MEMBERS_AND_REQUESTS', }, { type: 'ADD_MEMBERS_LINK', }, ...[...data] .sort((a, b) => { const aIsOwner = a.did === primaryMember?.did const bIsOwner = b.did === primaryMember?.did const aIsSelf = a.did === currentAccount?.did const bIsSelf = b.did === currentAccount?.did if (aIsOwner !== bIsOwner) return aIsOwner ? -1 : 1 if (aIsSelf !== bIsSelf) return aIsSelf ? -1 : 1 return 0 }) .map(profile => ({ type: 'CHAT_MEMBER', profile, status: primaryMember?.did === profile.did ? 'owner' : invites.includes(profile.did) ? 'invited' : 'member', })), ] function renderItem({item}: {item: Item}) { switch (item.type) { case 'MEMBERS_AND_REQUESTS': return case 'ADD_MEMBERS_LINK': return case 'CHAT_MEMBER': return default: return null } } if (convoState.status === ConvoStatus.Error) { return ( <> convoState.error.retry()} sideBorders={false} /> ) } return ( ) : ( ) } renderItem={renderItem} sideBorders={false} windowSize={11} onEndReachedThreshold={IS_NATIVE ? 1.5 : 0} /> ) } function MembersAndRequests({ memberCount, requestCount, }: { memberCount: number requestCount: number }) { const t = useTheme() const {t: l} = useLingui() const convoState = useConvo() const {currentAccount} = useSession() const isOwner = currentAccount?.did == null ? false : convoState.getPrimaryMember?.()?.did === currentAccount.did return ( Members{' '} {l`${memberCount}/${MEMBER_LIMIT}`} {isOwner && requestCount > 0 ? ( {l`${plural(requestCount, { one: '# request', other: '# requests', })}`} ) : null} ) } function AddMembersLink() { const t = useTheme() const {t: l} = useLingui() const convoState = useConvo() const {currentAccount} = useSession() const addMembersControl = Dialog.useDialogControl() const isOwner = currentAccount?.did == null ? false : convoState.getPrimaryMember?.()?.did === currentAccount.did if (!isOwner) { return null } return ( <> [ a.flex_row, a.align_center, a.justify_between, pressed && web({outline: 'none'}), ]} onPress={() => addMembersControl.open()}> {({pressed}) => ( <> Add members )} { // TODO Add members here addMembersControl.close() }} /> ) } function Member({ profile, status, }: { profile: Shadow status: 'owner' | 'member' | 'invited' }) { const navigation = useNavigation() const t = useTheme() const {t: l} = useLingui() const {currentAccount} = useSession() const moderationOpts = useModerationOpts() const moderation = useMemo( () => moderationOpts ? moderateProfile(profile, moderationOpts) : undefined, [profile, moderationOpts], ) if (!moderation) return null const isDeletedAccount = profile.handle === 'missing.invalid' const displayName = isDeletedAccount ? l`Deleted Account` : sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), ) let statusBadge: React.ReactNode | null = null if (currentAccount?.did === profile.did) { switch (status) { case 'owner': statusBadge = break } } else { statusBadge = } return ( { navigation.navigate('Profile', {name: profile.did}) }}> {displayName} {sanitizeHandle(profile.handle, '@')} {statusBadge} ) } function StatusBadge({ label, style, }: { label: string style?: StyleProp }) { const t = useTheme() return ( {label} ) } function StatusButton({ label, style, ...rest }: { label: string style?: StyleProp } & TriggerChildProps['props']) { const t = useTheme() return ( {label} ) } function MemberMenu({ profile, type, }: { profile: Shadow type: 'owner' | 'member' | 'invited' }) { const navigation = useNavigation() const t = useTheme() const {t: l} = useLingui() const ax = useAnalytics() const requireEmailVerification = useRequireEmailVerification() const convoState = useConvo() const {currentAccount} = useSession() const blockMemberPrompt = Prompt.usePromptControl() const isOwner = currentAccount?.did == null ? false : convoState.getPrimaryMember?.()?.did === currentAccount.did const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did) const {mutate: initiateConvo} = useGetConvoForMembers({ onSuccess: ({convo}) => { ax.metric('chat:open', {logContext: 'ProfileHeader'}) navigation.navigate('MessagesConversation', {conversation: convo.id}) }, onError: () => { Toast.show(l`Failed to create conversation`) }, }) const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) const messageMember = () => { if (!convoAvailability?.canChat) { return } if (convoAvailability.convo) { ax.metric('chat:open', {logContext: 'ProfileHeader'}) navigation.navigate('MessagesConversation', { conversation: convoAvailability.convo.id, }) } else { ax.metric('chat:create', {logContext: 'ProfileHeader'}) initiateConvo([profile.did]) } } const handleMessageMember = requireEmailVerification(messageMember, { instructions: [ Before you can message another user, you must first verify your email. , ], }) const handleBlockMember = async () => { if (profile.viewer?.blocking) { try { await queueUnblock() 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(l`There was an issue! ${e.toString()}`, { type: 'error', }) } } } else { try { await queueBlock() 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(l`There was an issue! ${e.toString()}`, { type: 'error', }) } } } } const moderationOpts = useModerationOpts() const moderation = useMemo( () => moderationOpts ? moderateProfile(profile, moderationOpts) : undefined, [profile, moderationOpts], ) if (!moderation) return null const isDeletedAccount = profile.handle === 'missing.invalid' const displayName = isDeletedAccount ? l`Deleted Account` : sanitizeDisplayName( profile.displayName || profile.handle, moderation.ui('displayName'), ) return ( <> {({props, state, control: menuControl}) => type === 'owner' || type === 'invited' ? ( ) : ( ) } { navigation.navigate('Profile', {name: profile.did}) }}> Go to profile Message {type === 'owner' || type === 'member' ? ( blockMemberPrompt.open()}> Block ) : null} {isOwner ? ( {}}> Remove from chat ) : null} {isOwner && type === 'invited' ? ( {}}> Uninvite ) : null} void handleBlockMember()} /> ) } function SettingsHeader({ convo, profiles, }: { convo: ChatBskyConvoDefs.ConvoView profiles: bsky.profile.AnyProfileView[] }) { const t = useTheme() const {t: l} = useLingui() const navigation = useNavigation() const convoState = useConvo() const {currentAccount} = useSession() const isOwner = currentAccount?.did == null ? false : convoState.getPrimaryMember?.()?.did === currentAccount.did const {mutate: muteConvo} = useMuteConvo(convo.id, { onSuccess: data => { if (data.convo.muted) { Toast.show(l({message: 'Group chat muted', context: 'toast'})) } else { Toast.show(l({message: 'Group chat unmuted', context: 'toast'})) } }, onError: e => { logger.error('Failed to mute group chat', {message: e}) Toast.show(l`Failed to mute group chat`, { type: 'error', }) }, }) const {mutate: leaveConvo} = useLeaveConvo(convo.id, { onMutate: () => { navigation.dispatch(StackActions.pop(2)) }, onError: e => { logger.error('Failed to leave group chat', {message: e}) Toast.show(l({message: 'Failed to leave group chat', context: 'toast'}), { type: 'error', }) }, }) const editNamePrompt = Prompt.usePromptControl() const inviteLinkPrompt = Prompt.usePromptControl() const lockChatPrompt = Prompt.usePromptControl() const leaveChatPrompt = Prompt.usePromptControl() const [groupName, setGroupName] = useState( convoState.getGroupInfo?.()?.name ?? '', ) const [newGroupName, setNewGroupName] = useState(groupName) const [isLocked, setIsLocked] = useState(false) const handleToggleMute = () => { muteConvo({mute: !convo?.muted}) } const handleLeaveChat = () => { leaveChatPrompt.open() } const handleReportChat = () => {} const handlePromptName = () => { editNamePrompt.open() } const handleEditName = () => { setGroupName(newGroupName) editNamePrompt.close() } const handlePromptInviteLink = () => { inviteLinkPrompt.open() } const handleConfirmInviteLink = () => { inviteLinkPrompt.close() } const handlePromptLock = () => { lockChatPrompt.open() } const handleConfirmLock = () => { setIsLocked(true) } const handleUnlock = () => { setIsLocked(false) } return ( <> {groupName} Created April 2, 2026 {isOwner ? ( ) : null} {isOwner ? ( ) : null} {isOwner ? null : ( )} {isOwner ? null : ( )} ) } function SettingsHeaderPlaceholder() { const t = useTheme() const {t: l} = useLingui() return ( {l`…`} … ) } function SettingsButton({ color = 'secondary', icon, label, text, onPress, }: { color?: ButtonColor icon: React.ComponentType label: string text: string onPress: () => void }) { const t = useTheme() return ( {text} ) } function SettingsButtonPlaceholder() { const t = useTheme() const {t: l} = useLingui() return ( {l`…`} ) } function EditNamePrompt({ control, value, onChangeText, onConfirm, }: { control: Dialog.DialogOuterProps['control'] value: string onChangeText: (value: string) => void onConfirm: () => void }) { const {t: l} = useLingui() return ( <> Edit group name ) } function InviteLinkPrompt({ control, onConfirm, }: { control: Dialog.DialogOuterProps['control'] onConfirm: () => void }) { const {t: l} = useLingui() return ( ) } function LockChatPrompt({ control, onConfirm, }: { control: Dialog.DialogOuterProps['control'] onConfirm: () => void }) { const {t: l} = useLingui() return ( ) } function LeaveChatPrompt({ control, groupName, onConfirm, }: { control: Dialog.DialogOuterProps['control'] groupName: string onConfirm: () => void }) { const {t: l} = useLingui() return ( ) } function BlockMemberPrompt({ control, onConfirm, }: { control: Dialog.DialogOuterProps['control'] onConfirm: () => void }) { const {t: l} = useLingui() return ( ) } function SubtleHoverWrapper({children}: React.PropsWithChildren) { const { state: hover, onIn: onHoverIn, onOut: onHoverOut, } = useInteractionState() return ( {children} ) }