import {useState} from 'react' import {View} from 'react-native' import { ChatBskyActorDefs, ChatBskyConvoDefs, type ModerationOpts, } from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset' import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' import { type CommonNavigatorParams, type NativeStackScreenProps, type NavigationProp, } from '#/lib/routes/types' import {logger} from '#/logger' import {ConvoProvider, isConvoActive, useConvo} from '#/state/messages/convo' import {ConvoStatus} from '#/state/messages/convo/types' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name' import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members' import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests' import {useLockConvo} from '#/state/queries/messages/lock-conversation' import {useMuteConvo} from '#/state/queries/messages/mute-conversation' import {useSession} from '#/state/session' import {List} from '#/view/com/util/List' import {atoms as a, useTheme} from '#/alf' import {AvatarBubbles} from '#/components/AvatarBubbles' import {Button, type ButtonColor, ButtonIcon} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {AfterReportConversationDialog} from '#/components/dms/AfterReportConversationDialog' import {ReportConversationDialog} from '#/components/dms/ReportConversationDialog' import { type ConvoWithDetails, type GroupConvoMember, } from '#/components/dms/util' import {Error} from '#/components/Error' 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 {type Props as SVGIconProps} from '#/components/icons/common' import {EditBig_Stroke2_Corner2_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 * as Layout from '#/components/Layout' import {Loader} from '#/components/Loader' import * as Prompt from '#/components/Prompt' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import * as bsky from '#/types/bsky' import {InviteLinkDialog} from '../components/InviteLinkDialog' import {AddMembersLink} from './AddMembersLink' import {Member, MemberPlaceholder} from './Member' import {MembersAndRequests} from './MembersAndRequests' import {EditNamePrompt, LeaveChatPrompt, LockChatPrompt} from './prompts' type Item = | {type: 'MEMBERS_AND_REQUESTS'; key: string} | {type: 'ADD_MEMBERS_LINK'; key: string} | { type: 'CHAT_MEMBER' key: string profile: GroupConvoMember status: 'owner' | 'standard' } | { type: 'CHAT_MEMBER_PLACEHOLDER' key: string } type Props = NativeStackScreenProps< CommonNavigatorParams, 'MessagesConversationSettings' > export function MessagesConversationSettingsScreen({route}: Props) { const convoId = route.params.conversation return ( Group chat settings ) } function SettingsInner() { const {t: l} = useLingui() const convoState = useConvo() const navigation = useNavigation() const moderationOpts = useModerationOpts() if (convoState.status === ConvoStatus.Error) { return ( convoState.error.retry()} sideBorders={false} /> ) } if (!convoState.convo || !moderationOpts) { return ( ) } if (convoState.convo.kind !== 'group') { return ( { if (navigation.canGoBack()) { navigation.goBack() } else { navigation.replace('Messages', {animation: 'pop'}) } }} /> ) } return ( ) } function keyExtractor(item: Item) { return item.key } function isGroupMember( member: ChatBskyActorDefs.ProfileViewBasic, ): member is GroupConvoMember { // Kind is missing when the account has been deleted. return ( member.kind === undefined || bsky.dangerousIsType( member.kind, ChatBskyActorDefs.isGroupConvoMember, ) ) } function GroupSettings({ convo, moderationOpts, isReady, }: { convo: Extract moderationOpts: ModerationOpts isReady: boolean }) { const [isPTRing, setIsPTRing] = useState(false) const initialNumToRender = useInitialNumToRender({minItemHeight: 68}) const bottomBarOffset = useBottomBarOffset() const {currentAccount} = useSession() const primaryMember = convo.primaryMember const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did const {data: memberListData = [], refetch} = useListConvoMembersQuery({ convoId: convo.view.id, placeholderData: convo.members, }) const {data: joinRequestsData, hasNextPage: hasMoreRequests} = useListJoinRequestsQuery({ convoId: convo.view.id, enabled: isOwner, }) const requestCount = joinRequestsData?.pages.reduce( (sum, page) => sum + page.requests.length, 0, ) ?? 0 const groupMembers = memberListData.filter(isGroupMember).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 }) const items: Item[] = [ { type: 'MEMBERS_AND_REQUESTS', key: 'members-and-requests', }, ...(isOwner && convo.details.lockStatus === 'unlocked' ? [{type: 'ADD_MEMBERS_LINK', key: 'add-members-link'} as const] : []), ] items.push( ...groupMembers.map( (profile): Item => ({ type: 'CHAT_MEMBER', key: profile.did, profile, status: primaryMember?.did === profile.did ? 'owner' : 'standard', }), ), ) const placeholderCount = Math.max( 0, convo.details.memberCount - groupMembers.length, ) for (let i = 0; i < placeholderCount; i++) { items.push({ type: 'CHAT_MEMBER_PLACEHOLDER', key: `chat-member-placeholder-${i}`, }) } function renderItem({item}: {item: Item}) { switch (item.type) { case 'MEMBERS_AND_REQUESTS': return ( ) case 'ADD_MEMBERS_LINK': return case 'CHAT_MEMBER': return ( ) case 'CHAT_MEMBER_PLACEHOLDER': return default: return null } } const onRefresh = async () => { setIsPTRing(true) try { await refetch() } catch (err) { logger.error('Failed to refresh group chat members', {message: err}) } setIsPTRing(false) } return ( } renderItem={renderItem} sideBorders={false} windowSize={11} refreshing={isPTRing} onRefresh={() => void onRefresh()} /> ) } function SettingsHeader({ convo, isOwner, moderationOpts, isReady, }: { convo: Extract isOwner: boolean moderationOpts: ModerationOpts isReady: boolean }) { const t = useTheme() const {i18n, t: l} = useLingui() const navigation = useNavigation() const groupName = convo.details.name const [newGroupName, setNewGroupName] = useState(groupName) const lockStatus = convo.details.lockStatus const {joinLink} = convo.details const isJoinLinkEnabled = isOwner || joinLink?.enabledStatus === 'enabled' const reportSubjectDid = convo.primaryMember?.did const {mutate: editGroupName, isPending: isEditingName} = useEditGroupChatName(convo.view.id, { onSuccess: () => { Toast.show(l({message: 'Group chat name updated', context: 'toast'})) }, onError: e => { setNewGroupName(groupName) logger.error('Failed to edit group chat name', {message: e}) Toast.show(l`Failed to edit group chat name`, {type: 'error'}) }, }) const {mutate: muteConvo, isPending: isMuting} = useMuteConvo(convo.view.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, isPending: isLeaving} = useLeaveConvo( convo.view.id, { onSuccess: () => { navigation.replace('Messages', {animation: 'pop'}) }, 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 {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 inviteLinkDialog = Dialog.useDialogControl() const editNamePrompt = Prompt.usePromptControl() const lockChatPrompt = Prompt.usePromptControl() const leaveChatPrompt = Prompt.usePromptControl() const reportControl = Prompt.usePromptControl() const deleteControl = Prompt.usePromptControl() const createdAt = new Date(convo.details.createdAt) const canLockGroupChat = isOwner && lockStatus !== 'locked-permanently' return ( <> {groupName} Created{' '} {i18n.date(createdAt, { month: 'long', day: 'numeric', year: 'numeric', })} muteConvo({mute: !convo.view.muted})} /> {isOwner ? ( { setNewGroupName(groupName) editNamePrompt.open() }} /> ) : null} {isJoinLinkEnabled ? ( ) : null} {canLockGroupChat ? ( lockConvo({lock: false}) : lockChatPrompt.open } /> ) : null} {!isOwner && reportSubjectDid ? ( ) : null} {!isOwner ? ( ) : null} editGroupName({name: newGroupName})} /> {convo.primaryMember && ( )} lockConvo({lock: true})} /> {reportSubjectDid ? ( <> ) : null} ) } function SettingsButton({ color = 'secondary', disabled, icon, label, text, onPress, }: { color?: ButtonColor disabled?: boolean icon: React.ComponentType label: string text: string onPress: () => void }) { const t = useTheme() return ( {text} ) }