From 877d5b0d9c6c5b02c07ff1480ec1f933b8f4aff0 Mon Sep 17 00:00:00 2001
From: DS Boyce <260543580+ds-boyce@users.noreply.github.com>
Date: Thu, 23 Apr 2026 15:49:45 -0700
Subject: [PATCH] Tweak conversation settings (#10347)
---
src/screens/Messages/ConversationSettings.tsx | 1207 -----------------
.../ConversationSettings/AddMembersLink.tsx | 109 ++
.../Messages/ConversationSettings/Member.tsx | 103 ++
.../ConversationSettings/MemberMenu.tsx | 252 ++++
.../MembersAndRequests.tsx | 62 +
.../ConversationSettings/StatusBadge.tsx | 44 +
.../SubtleHoverWrapper.tsx | 27 +
.../ConversationSettings/constants.ts | 2 +
.../Messages/ConversationSettings/index.tsx | 555 ++++++++
.../Messages/ConversationSettings/prompts.tsx | 119 ++
10 files changed, 1273 insertions(+), 1207 deletions(-)
delete mode 100644 src/screens/Messages/ConversationSettings.tsx
create mode 100644 src/screens/Messages/ConversationSettings/AddMembersLink.tsx
create mode 100644 src/screens/Messages/ConversationSettings/Member.tsx
create mode 100644 src/screens/Messages/ConversationSettings/MemberMenu.tsx
create mode 100644 src/screens/Messages/ConversationSettings/MembersAndRequests.tsx
create mode 100644 src/screens/Messages/ConversationSettings/StatusBadge.tsx
create mode 100644 src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx
create mode 100644 src/screens/Messages/ConversationSettings/constants.ts
create mode 100644 src/screens/Messages/ConversationSettings/index.tsx
create mode 100644 src/screens/Messages/ConversationSettings/prompts.tsx
diff --git a/src/screens/Messages/ConversationSettings.tsx b/src/screens/Messages/ConversationSettings.tsx
deleted file mode 100644
index 0e1a01a639..0000000000
--- a/src/screens/Messages/ConversationSettings.tsx
+++ /dev/null
@@ -1,1207 +0,0 @@
-import {useState} from 'react'
-import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
-import {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 {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
-import {
- type CommonNavigatorParams,
- type NativeStackScreenProps,
- type NavigationProp,
-} from '#/lib/routes/types'
-import {sanitizeHandle} from '#/lib/strings/handles'
-import {logger} from '#/logger'
-import {useProfileShadow} from '#/state/cache/profile-shadow'
-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 {useAddGroupMembers} from '#/state/queries/messages/add-group-members'
-import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name'
-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 {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests'
-import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
-import {useRemoveFromGroupChat} from '#/state/queries/messages/remove-from-group'
-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 {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
-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_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 {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'
-import {InviteLinkDialog} from './components/InviteLinkDialog'
-
-const MEMBER_LIMIT = 50
-const ROW_SPACING = 10
-
-const dateFormatter = new Intl.DateTimeFormat(undefined, {
- month: 'long',
- day: 'numeric',
- year: 'numeric',
-})
-
-type Item =
- | {
- type: 'MEMBERS_AND_REQUESTS'
- }
- | {
- type: 'ADD_MEMBERS_LINK'
- }
- | {
- type: 'CHAT_MEMBER'
- profile: bsky.profile.AnyProfileView
- status: 'owner' | 'standard' | 'invited'
- }
-
-type Props = NativeStackScreenProps<
- CommonNavigatorParams,
- 'MessagesConversationSettings'
->
-
-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({convoId}: {convoId: string}) {
- const {t: l} = useLingui()
-
- const initialNumToRender = useInitialNumToRender({minItemHeight: 68})
- const bottomBarOffset = useBottomBarOffset()
-
- const convoState = useConvo()
- const {currentAccount} = useSession()
-
- const convo = convoState.convo
- ? parseConvoView(convoState.convo, currentAccount?.did)
- : null
- const primaryMember = convo?.primaryMember
- const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did
-
- const data: bsky.profile.AnyProfileView[] = convo?.members ?? []
- // TODO Need this data in order to populate this array. -dsb
- const invites: string[] = []
-
- const {data: joinRequestsData, hasNextPage: hasMoreRequests} =
- useListJoinRequestsQuery({
- convoId,
- enabled: isOwner,
- })
- const requestCount =
- joinRequestsData?.pages.reduce(
- (sum, page) => sum + page.requests.length,
- 0,
- ) ?? 0
-
- const items: Item[] = [
- {
- 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): Item => ({
- type: 'CHAT_MEMBER',
- profile,
- status:
- primaryMember?.did === profile.did
- ? 'owner'
- : invites.includes(profile.did)
- ? 'invited'
- : 'standard',
- }),
- ),
- ]
-
- function renderItem({item}: {item: Item}) {
- switch (item.type) {
- case 'MEMBERS_AND_REQUESTS':
- return (
-
- )
- case 'ADD_MEMBERS_LINK':
- return (
- profile.did)}
- />
- )
- case 'CHAT_MEMBER':
- return convo ? (
-
- ) : null
- 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,
- hasMoreRequests,
- isOwner,
-}: {
- memberCount: number
- requestCount: number
- hasMoreRequests: boolean
- isOwner: boolean
-}) {
- const t = useTheme()
- const {t: l} = useLingui()
-
- return (
-
-
-
- Members
-
-
- {l({
- message: `${memberCount}/${MEMBER_LIMIT}`,
- comment:
- 'The number of group chat members out of the total number of permitted users.',
- })}
-
-
- {isOwner && requestCount > 0 ? (
-
- {hasMoreRequests
- ? l({
- message: `${requestCount}+ requests`,
- comment:
- 'Displayed when there are more than 50 requests to join a group chat',
- })
- : l({
- message: `${plural(requestCount, {
- one: '# request',
- other: '# requests',
- })}`,
- comment: 'The number of requests to join a group chat.',
- })}
-
- ) : null}
-
- )
-}
-
-function AddMembersLink({
- convo,
- isOwner,
- members,
-}: {
- convo: ConvoWithDetails | null
- isOwner: boolean
- members: string[]
-}) {
- const t = useTheme()
- const {t: l} = useLingui()
-
- const addMembersControl = Dialog.useDialogControl()
-
- const convoId = convo?.view.id
- const {mutate: addGroupMembers} = useAddGroupMembers(convoId, {
- onSuccess: () => {
- addMembersControl.close()
- },
- onError: e => {
- logger.error('Failed to add group chat members', {message: e})
- Toast.show(l`Failed to add members`, {type: 'error'})
- },
- })
-
- if (!isOwner) {
- return null
- }
-
- return (
- <>
-
-
- [
- a.flex_row,
- a.align_center,
- a.justify_between,
- pressed && web({outline: 'none'}),
- ]}
- onPress={() => addMembersControl.open()}>
- {({pressed}) => (
- <>
-
-
-
-
-
-
- Add members
-
-
-
-
- >
- )}
-
-
-
-
-
-
- {
- addGroupMembers({members})
- }}
- />
-
- >
- )
-}
-
-function Member({
- convo,
- profile: profileUnshadowed,
- status,
- isOwner,
-}: {
- convo: ConvoWithDetails
- profile: bsky.profile.AnyProfileView
- status: 'owner' | 'standard' | 'invited'
- isOwner: boolean
-}) {
- const navigation = useNavigation()
- const t = useTheme()
- const {t: l} = useLingui()
-
- const profile = useProfileShadow(profileUnshadowed)
- const {currentAccount} = useSession()
- const moderationOpts = useModerationOpts()
- const moderation = moderationOpts
- ? moderateProfile(profile, moderationOpts)
- : undefined
-
- if (!moderation) return null
-
- const isDeletedAccount = profile.handle === 'missing.invalid'
- const displayName = isDeletedAccount
- ? l`Deleted Account`
- : createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
-
- const isSelf = currentAccount?.did === profile.did
- let statusBadge: React.ReactNode | null = null
- if (isSelf) {
- if (status === 'owner') {
- statusBadge =
- }
- } else {
- statusBadge = convo ? (
-
- ) : null
- }
-
- return (
-
-
- {
- navigation.navigate('Profile', {name: profile.did})
- }}>
-
-
-
- {displayName}
-
-
- {sanitizeHandle(profile.handle, '@')}
-
-
-
- {statusBadge}
-
-
- )
-}
-
-function StatusBadge({
- label,
- style,
- pressableProps,
-}: {
- label: string
- style?: StyleProp
- pressableProps?: TriggerChildProps['props']
-}) {
- const t = useTheme()
-
- const badgeStyle = [
- a.rounded_xs,
- t.atoms.bg_contrast_50,
- {
- paddingTop: 3,
- paddingBottom: 3,
- paddingLeft: 6,
- paddingRight: 6,
- },
- style,
- ]
-
- const labelText = (
-
- {label}
-
- )
-
- if (pressableProps) {
- return (
-
- {labelText}
-
- )
- }
- return {labelText}
-}
-
-function MemberMenu({
- convo,
- profile,
- displayName,
- type,
- isOwner,
-}: {
- convo: ConvoWithDetails
- profile: Shadow
- type: 'owner' | 'standard' | 'invited'
- displayName: string
- isOwner: boolean
-}) {
- const navigation = useNavigation()
- const t = useTheme()
- const {t: l} = useLingui()
- const ax = useAnalytics()
-
- const requireEmailVerification = useRequireEmailVerification()
-
- const blockMemberPrompt = Prompt.usePromptControl()
-
- const [menuDidOpen, setMenuDidOpen] = useState(false)
- const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, {
- enabled: menuDidOpen,
- })
- const {mutate: initiateConvo} = useGetConvoForMembers({
- onSuccess: ({convo}) => {
- ax.metric('chat:open', {logContext: 'ConvoSettings'})
- navigation.navigate('MessagesConversation', {conversation: convo.id})
- },
- onError: () => {
- Toast.show(l`Failed to create conversation`)
- },
- })
- const convoId = convo?.view.id
- const {mutate: removeMembers} = useRemoveFromGroupChat(convoId, {
- onError: e => {
- logger.error('Failed to remove group chat member', {message: e})
- Toast.show(l`Failed to remove group chat member`)
- },
- })
- const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
-
- const messageMember = () => {
- if (!convoAvailability?.canChat) {
- return
- }
-
- if (convoAvailability.convo) {
- ax.metric('chat:open', {logContext: 'ConvoSettings'})
- navigation.navigate('MessagesConversation', {
- conversation: convoAvailability.convo.id,
- })
- } else {
- ax.metric('chat:create', {logContext: 'ConvoSettings'})
- 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') {
- 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') {
- logger.error('Failed to block account', {message: e})
- Toast.show(l`There was an issue! ${e.toString()}`, {
- type: 'error',
- })
- }
- }
- }
- }
-
- return (
- <>
-
-
- {({props, state, control: menuControl}) => {
- const triggerProps = {
- ...props,
- onPress: () => {
- setMenuDidOpen(true)
- props.onPress()
- },
- }
- return type === 'owner' || type === 'invited' ? (
-
- ) : (
-
-
-
- )
- }}
-
-
-
- {
- navigation.navigate('Profile', {name: profile.did})
- }}>
-
- Go to profile
-
-
-
-
-
- Message
-
-
-
-
-
-
- {type === 'owner' || type === 'standard' ? (
- blockMemberPrompt.open()}>
-
- Block
-
-
-
- ) : null}
- {isOwner && type !== 'invited' ? (
- removeMembers({members: [profile.did]})}>
-
- Remove from chat
-
-
-
- ) : null}
- {isOwner && type === 'invited' ? (
- {}}>
-
- Uninvite
-
-
-
- ) : null}
-
-
-
- void handleBlockMember()}
- />
- >
- )
-}
-
-function SettingsHeader({
- convo,
- isOwner,
-}: {
- convo: Extract
- isOwner: boolean
-}) {
- const t = useTheme()
- const {t: l} = useLingui()
-
- const navigation = useNavigation()
-
- const groupName = convo.details.name
- const [newGroupName, setNewGroupName] = useState(groupName)
-
- const [isLocked, setIsLocked] = useState(false)
-
- // TODO Enable this once the feature is working end-to-end. -dsb
- // const {joinLink} = convo.details
- const isJoinLinkEnabled = false
- // const isJoinLinkEnabled =
- // isOwner || (!isOwner && joinLink?.enabledStatus === 'enabled')
-
- // TODO Enable this once the feature is working end-to-end. -dsb
- const isReportLinkEnabled = false
-
- const {mutate: editGroupName} = useEditGroupChatName(convo.view.id, {
- 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} = 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} = useLeaveConvo(convo.view.id, {
- onSuccess: () => {
- 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 inviteLinkDialog = Dialog.useDialogControl()
- const editNamePrompt = Prompt.usePromptControl()
- const lockChatPrompt = Prompt.usePromptControl()
- const leaveChatPrompt = Prompt.usePromptControl()
-
- const handleToggleMute = () => {
- muteConvo({mute: !convo.view.muted})
- }
-
- // TODO Need to implement this when the backend is ready. -dsb
- const handleReportChat = () => {}
-
- const handlePromptName = () => {
- setNewGroupName(groupName)
- editNamePrompt.open()
- }
-
- const handleEditName = () => {
- editGroupName({name: newGroupName})
- }
-
- const handleConfirmLock = () => {
- setIsLocked(true)
- }
-
- const handleUnlock = () => {
- setIsLocked(false)
- }
-
- // TODO The creation date doesn't exist yet. -dsb
- const createdAt = new Date()
-
- return (
- <>
-
-
-
-
-
- {groupName}
-
-
- Created {dateFormatter.format(createdAt)}
-
-
-
- {isOwner ? (
-
- ) : null}
- {isJoinLinkEnabled ? (
-
- ) : null}
- {isOwner ? (
-
- ) : null}
- {isOwner ? null : isReportLinkEnabled ? (
-
- ) : null}
- {isOwner ? null : (
-
- )}
-
-
-
-
-
-
- >
- )
-}
-
-function SettingsHeaderPlaceholder() {
- const t = useTheme()
-
- return (
-
-
-
-
-
- …
-
-
- …
-
-
-
-
-
-
-
-
- )
-}
-
-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 (
-
-
-
- …
-
-
- )
-}
-
-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 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}
-
- )
-}
diff --git a/src/screens/Messages/ConversationSettings/AddMembersLink.tsx b/src/screens/Messages/ConversationSettings/AddMembersLink.tsx
new file mode 100644
index 0000000000..5fbcfaaa4b
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/AddMembersLink.tsx
@@ -0,0 +1,109 @@
+import {Pressable, View} from 'react-native'
+import {Trans, useLingui} from '@lingui/react/macro'
+
+import {logger} from '#/logger'
+import {useAddGroupMembers} from '#/state/queries/messages/add-group-members'
+import {atoms as a, useTheme, web} from '#/alf'
+import * as Dialog from '#/components/Dialog'
+import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
+import {type ConvoWithDetails} from '#/components/dms/util'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronIcon} from '#/components/icons/Chevron'
+import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
+import * as Toast from '#/components/Toast'
+import {Text} from '#/components/Typography'
+import {SubtleHoverWrapper} from './SubtleHoverWrapper'
+
+export function AddMembersLink({
+ convo,
+ members,
+}: {
+ convo: ConvoWithDetails
+ members: string[]
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const addMembersControl = Dialog.useDialogControl()
+
+ const convoId = convo.view.id
+ const {mutate: addGroupMembers} = useAddGroupMembers(convoId, {
+ onSuccess: () => {
+ addMembersControl.close()
+ },
+ onError: e => {
+ logger.error('Failed to add group chat members', {message: e})
+ Toast.show(l`Failed to add members`, {type: 'error'})
+ },
+ })
+
+ return (
+ <>
+
+
+ [
+ a.flex_row,
+ a.align_center,
+ a.justify_between,
+ pressed && web({outline: 'none'}),
+ ]}
+ onPress={addMembersControl.open}>
+ {({pressed}) => (
+ <>
+
+
+
+
+
+
+ Add members
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+ {
+ addGroupMembers({members})
+ }}
+ />
+
+ >
+ )
+}
diff --git a/src/screens/Messages/ConversationSettings/Member.tsx b/src/screens/Messages/ConversationSettings/Member.tsx
new file mode 100644
index 0000000000..8d7cee84fc
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/Member.tsx
@@ -0,0 +1,103 @@
+import {Pressable, View} from 'react-native'
+import {moderateProfile} from '@atproto/api'
+import {useLingui} from '@lingui/react/macro'
+import {useNavigation} from '@react-navigation/native'
+
+import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
+import {type NavigationProp} from '#/lib/routes/types'
+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 {atoms as a, useTheme, web} from '#/alf'
+import {type ConvoWithDetails} from '#/components/dms/util'
+import {Text} from '#/components/Typography'
+import type * as bsky from '#/types/bsky'
+import {MemberMenu} from './MemberMenu'
+import {StatusBadge} from './StatusBadge'
+import {SubtleHoverWrapper} from './SubtleHoverWrapper'
+
+export function Member({
+ convo,
+ profile: profileUnshadowed,
+ status,
+ isOwner,
+}: {
+ convo: ConvoWithDetails
+ profile: bsky.profile.AnyProfileView
+ status: 'owner' | 'standard' | 'invited'
+ isOwner: boolean
+}) {
+ const navigation = useNavigation()
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const profile = useProfileShadow(profileUnshadowed)
+ const {currentAccount} = useSession()
+ const moderationOpts = useModerationOpts()
+ const moderation = moderationOpts
+ ? moderateProfile(profile, moderationOpts)
+ : undefined
+
+ // TODO Render a skeleton here. -dsb
+ if (!moderation) return null
+
+ const isDeletedAccount = profile.handle === 'missing.invalid'
+ const displayName = isDeletedAccount
+ ? l`Deleted Account`
+ : createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
+
+ const isSelf = currentAccount?.did === profile.did
+ let statusBadge: React.ReactNode | null = null
+ if (isSelf) {
+ if (status === 'owner') {
+ statusBadge =
+ }
+ } else {
+ statusBadge = (
+
+ )
+ }
+
+ return (
+
+
+ {
+ navigation.navigate('Profile', {name: profile.handle})
+ }}>
+
+
+
+ {displayName}
+
+
+ {sanitizeHandle(profile.handle, '@')}
+
+
+
+ {statusBadge}
+
+
+ )
+}
diff --git a/src/screens/Messages/ConversationSettings/MemberMenu.tsx b/src/screens/Messages/ConversationSettings/MemberMenu.tsx
new file mode 100644
index 0000000000..127a474239
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/MemberMenu.tsx
@@ -0,0 +1,252 @@
+import {useState} from 'react'
+import {Pressable} from 'react-native'
+import {Trans, useLingui} from '@lingui/react/macro'
+import {useNavigation} from '@react-navigation/native'
+
+import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
+import {type NavigationProp} from '#/lib/routes/types'
+import {logger} from '#/logger'
+import {type Shadow} from '#/state/cache/types'
+import {useGetConvoAvailabilityQuery} from '#/state/queries/messages/get-convo-availability'
+import {useGetConvoForMembers} from '#/state/queries/messages/get-convo-for-members'
+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 {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'
+import {
+ Person_Stroke2_Corner2_Rounded as PersonIcon,
+ PersonX_Stroke2_Corner0_Rounded as PersonXIcon,
+} from '#/components/icons/Person'
+import * as Menu from '#/components/Menu'
+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 {StatusBadge} from './StatusBadge'
+
+export function MemberMenu({
+ convo,
+ profile,
+ displayName,
+ type,
+ isOwner,
+}: {
+ convo: ConvoWithDetails
+ profile: Shadow
+ type: 'owner' | 'standard' | 'invited'
+ displayName: string
+ isOwner: boolean
+}) {
+ const navigation = useNavigation()
+ const t = useTheme()
+ const {t: l} = useLingui()
+ const ax = useAnalytics()
+
+ const requireEmailVerification = useRequireEmailVerification()
+
+ const blockMemberPrompt = Prompt.usePromptControl()
+
+ const [menuDidOpen, setMenuDidOpen] = useState(false)
+ const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did, {
+ enabled: menuDidOpen,
+ })
+ const {mutate: initiateConvo} = useGetConvoForMembers({
+ onSuccess: ({convo}) => {
+ ax.metric('chat:open', {logContext: 'ConvoSettings'})
+ navigation.navigate('MessagesConversation', {conversation: convo.id})
+ },
+ onError: () => {
+ Toast.show(l`Failed to create conversation`, {type: 'error'})
+ },
+ })
+ const convoId = convo.view.id
+ const {mutate: removeMembers} = useRemoveFromGroupChat(convoId, {
+ onError: e => {
+ logger.error('Failed to remove group chat member', {message: e})
+ Toast.show(l`Failed to remove group chat member`, {type: 'error'})
+ },
+ })
+ const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
+
+ const messageMember = () => {
+ if (!convoAvailability?.canChat) {
+ return
+ }
+
+ if (convoAvailability.convo) {
+ ax.metric('chat:open', {logContext: 'ConvoSettings'})
+ navigation.navigate('MessagesConversation', {
+ conversation: convoAvailability.convo.id,
+ })
+ } else {
+ ax.metric('chat:create', {logContext: 'ConvoSettings'})
+ 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') {
+ 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') {
+ logger.error('Failed to block account', {message: e})
+ Toast.show(l`There was an issue! ${e.toString()}`, {
+ type: 'error',
+ })
+ }
+ }
+ }
+ }
+
+ 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'
+
+ return (
+ <>
+
+
+ {({props, state, control: menuControl}) => {
+ const isActive =
+ state.hovered || state.pressed || menuControl.isOpen
+ const triggerProps = {
+ ...props,
+ onPress: () => {
+ setMenuDidOpen(true)
+ props.onPress()
+ },
+ }
+ return type === 'owner' || type === 'invited' ? (
+
+ ) : (
+
+
+
+ )
+ }}
+
+
+
+ {
+ navigation.navigate('Profile', {name: profile.did})
+ }}>
+
+ Go to profile
+
+
+
+
+
+ Message
+
+
+
+
+
+
+ {canBlockMember ? (
+
+
+ Block
+
+
+
+ ) : null}
+ {canRemoveMember ? (
+ removeMembers({members: [profile.did]})}>
+
+ Remove from chat
+
+
+
+ ) : null}
+ {canUninviteMember ? (
+ {}}>
+
+ Uninvite
+
+
+
+ ) : null}
+
+
+
+ void handleBlockMember()}
+ />
+ >
+ )
+}
diff --git a/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx b/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx
new file mode 100644
index 0000000000..9dc26f7f5e
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/MembersAndRequests.tsx
@@ -0,0 +1,62 @@
+import {View} from 'react-native'
+import {plural} from '@lingui/core/macro'
+import {Trans, useLingui} from '@lingui/react/macro'
+
+import {atoms as a, useTheme} from '#/alf'
+import {InlineLinkText} from '#/components/Link'
+import {Text} from '#/components/Typography'
+import {MEMBER_LIMIT} from './constants'
+
+export function MembersAndRequests({
+ memberCount,
+ requestCount,
+ hasMoreRequests,
+ isOwner,
+}: {
+ memberCount: number
+ requestCount: number
+ hasMoreRequests: boolean
+ isOwner: boolean
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ return (
+
+
+
+ Members
+
+
+ {l({
+ message: `${memberCount}/${MEMBER_LIMIT}`,
+ comment:
+ 'The number of group chat members out of the total number of permitted users.',
+ })}
+
+
+ {isOwner && requestCount > 0 ? (
+
+ {hasMoreRequests
+ ? l({
+ message: `${requestCount}+ requests`,
+ comment:
+ 'Displayed when there are more than 50 requests to join a group chat',
+ })
+ : l({
+ message: plural(requestCount, {
+ one: '# request',
+ other: '# requests',
+ }),
+ comment: 'The number of requests to join a group chat.',
+ })}
+
+ ) : null}
+
+ )
+}
diff --git a/src/screens/Messages/ConversationSettings/StatusBadge.tsx b/src/screens/Messages/ConversationSettings/StatusBadge.tsx
new file mode 100644
index 0000000000..e5416bd033
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/StatusBadge.tsx
@@ -0,0 +1,44 @@
+import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import {type TriggerChildProps} from '#/components/Menu/types'
+import {Text} from '#/components/Typography'
+
+export function StatusBadge({
+ label,
+ style,
+ pressableProps,
+}: {
+ label: string
+ style?: StyleProp
+ pressableProps?: TriggerChildProps['props']
+}) {
+ const t = useTheme()
+
+ const badgeStyle = [
+ a.rounded_xs,
+ t.atoms.bg_contrast_50,
+ {
+ paddingTop: 3,
+ paddingBottom: 3,
+ paddingLeft: 6,
+ paddingRight: 6,
+ },
+ style,
+ ]
+
+ const labelText = (
+
+ {label}
+
+ )
+
+ if (pressableProps) {
+ return (
+
+ {labelText}
+
+ )
+ }
+ return {labelText}
+}
diff --git a/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx b/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx
new file mode 100644
index 0000000000..d7f032b06e
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/SubtleHoverWrapper.tsx
@@ -0,0 +1,27 @@
+import {View} from 'react-native'
+
+import {atoms as a} from '#/alf'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {SubtleHover} from '#/components/SubtleHover'
+
+export function SubtleHoverWrapper({
+ children,
+}: React.PropsWithChildren) {
+ const {
+ state: hover,
+ onIn: onHoverIn,
+ onOut: onHoverOut,
+ } = useInteractionState()
+
+ return (
+
+
+ {children}
+
+ )
+}
diff --git a/src/screens/Messages/ConversationSettings/constants.ts b/src/screens/Messages/ConversationSettings/constants.ts
new file mode 100644
index 0000000000..dc6b7b9257
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/constants.ts
@@ -0,0 +1,2 @@
+export const ROW_SPACING = 20
+export const MEMBER_LIMIT = 50
diff --git a/src/screens/Messages/ConversationSettings/index.tsx b/src/screens/Messages/ConversationSettings/index.tsx
new file mode 100644
index 0000000000..954e90e55a
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/index.tsx
@@ -0,0 +1,555 @@
+import {useState} from 'react'
+import {View} from 'react-native'
+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 {
+ type CommonNavigatorParams,
+ type NativeStackScreenProps,
+ type NavigationProp,
+} from '#/lib/routes/types'
+import {logger} from '#/logger'
+import {ConvoProvider, useConvo} from '#/state/messages/convo'
+import {ConvoStatus} from '#/state/messages/convo/types'
+import {useEditGroupChatName} from '#/state/queries/messages/edit-group-chat-name'
+import {useLeaveConvo} from '#/state/queries/messages/leave-conversation'
+import {useListJoinRequestsQuery} from '#/state/queries/messages/list-join-requests'
+import {useMuteConvo} from '#/state/queries/messages/mute-conversation'
+import {useSession} from '#/state/session'
+import {List} from '#/view/com/util/List'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {AvatarBubbles} from '#/components/AvatarBubbles'
+import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {type ConvoWithDetails, parseConvoView} 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 {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid'
+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 * as Prompt from '#/components/Prompt'
+import * as Toast from '#/components/Toast'
+import {Text} from '#/components/Typography'
+import {IS_NATIVE} from '#/env'
+import type * as bsky from '#/types/bsky'
+import {InviteLinkDialog} from '../components/InviteLinkDialog'
+import {AddMembersLink} from './AddMembersLink'
+import {ROW_SPACING} from './constants'
+import {Member} from './Member'
+import {MembersAndRequests} from './MembersAndRequests'
+import {EditNamePrompt, LeaveChatPrompt, LockChatPrompt} from './prompts'
+
+const dateFormatter = new Intl.DateTimeFormat(undefined, {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+})
+
+type Item =
+ | {
+ type: 'MEMBERS_AND_REQUESTS'
+ }
+ | {
+ type: 'ADD_MEMBERS_LINK'
+ }
+ | {
+ type: 'CHAT_MEMBER'
+ profile: bsky.profile.AnyProfileView
+ status: 'owner' | 'standard' | 'invited'
+ }
+
+type Props = NativeStackScreenProps<
+ CommonNavigatorParams,
+ 'MessagesConversationSettings'
+>
+
+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({convoId}: {convoId: string}) {
+ const {t: l} = useLingui()
+
+ const initialNumToRender = useInitialNumToRender({minItemHeight: 68})
+ const bottomBarOffset = useBottomBarOffset()
+
+ const convoState = useConvo()
+ const {currentAccount} = useSession()
+
+ const convo = convoState.convo
+ ? parseConvoView(convoState.convo, currentAccount?.did)
+ : null
+ const primaryMember = convo?.primaryMember
+ const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did
+
+ const data: bsky.profile.AnyProfileView[] = convo?.members ?? []
+ // TODO Need this data in order to populate this array. -dsb
+ const invites: string[] = []
+
+ const {data: joinRequestsData, hasNextPage: hasMoreRequests} =
+ useListJoinRequestsQuery({
+ convoId,
+ enabled: isOwner,
+ })
+ const requestCount =
+ joinRequestsData?.pages.reduce(
+ (sum, page) => sum + page.requests.length,
+ 0,
+ ) ?? 0
+
+ const items: Item[] = [
+ {
+ type: 'MEMBERS_AND_REQUESTS',
+ },
+ ...(isOwner ? [{type: 'ADD_MEMBERS_LINK'} as const] : []),
+ ...[...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): Item => ({
+ type: 'CHAT_MEMBER',
+ profile,
+ status:
+ primaryMember?.did === profile.did
+ ? 'owner'
+ : invites.includes(profile.did)
+ ? 'invited'
+ : 'standard',
+ }),
+ ),
+ ]
+
+ function renderItem({item}: {item: Item}) {
+ switch (item.type) {
+ case 'MEMBERS_AND_REQUESTS':
+ return (
+
+ )
+ case 'ADD_MEMBERS_LINK':
+ return convo ? (
+ profile.did)}
+ />
+ ) : null
+ case 'CHAT_MEMBER':
+ return convo ? (
+
+ ) : null
+ default:
+ return null
+ }
+ }
+
+ if (convoState.status === ConvoStatus.Error) {
+ return (
+ convoState.error.retry()}
+ sideBorders={false}
+ />
+ )
+ }
+
+ return (
+
+ ) : (
+
+ )
+ }
+ renderItem={renderItem}
+ sideBorders={false}
+ windowSize={11}
+ // TODO Paginate on relatedProfiles. -dsb
+ onEndReached={() => {}}
+ onEndReachedThreshold={IS_NATIVE ? 1.5 : 0}
+ />
+ )
+}
+
+function SettingsHeader({
+ convo,
+ isOwner,
+}: {
+ convo: Extract
+ isOwner: boolean
+}) {
+ const t = useTheme()
+ const {t: l} = useLingui()
+
+ const navigation = useNavigation()
+
+ const groupName = convo.details.name
+ const [newGroupName, setNewGroupName] = useState(groupName)
+
+ const [isLocked, setIsLocked] = useState(false)
+
+ // TODO Enable this once the feature is working end-to-end. -dsb
+ // const {joinLink} = convo.details
+ const isJoinLinkEnabled = false
+ // const isJoinLinkEnabled =
+ // isOwner || (!isOwner && joinLink?.enabledStatus === 'enabled')
+
+ // TODO Enable this once the feature is working end-to-end. -dsb
+ const isReportLinkEnabled = false
+
+ const {mutate: editGroupName} = useEditGroupChatName(convo.view.id, {
+ 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} = 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} = useLeaveConvo(convo.view.id, {
+ onSuccess: () => {
+ // Settings > Chat > Chat list
+ 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 inviteLinkDialog = Dialog.useDialogControl()
+ const editNamePrompt = Prompt.usePromptControl()
+ const lockChatPrompt = Prompt.usePromptControl()
+ const leaveChatPrompt = Prompt.usePromptControl()
+
+ const handleToggleMute = () => {
+ muteConvo({mute: !convo.view.muted})
+ }
+
+ // TODO Need to implement this when the backend is ready. -dsb
+ const handleReportChat = () => {}
+
+ const handlePromptName = () => {
+ setNewGroupName(groupName)
+ editNamePrompt.open()
+ }
+
+ const handleEditName = () => {
+ editGroupName({name: newGroupName})
+ }
+
+ const handleConfirmLock = () => {
+ setIsLocked(true)
+ }
+
+ const handleUnlock = () => {
+ setIsLocked(false)
+ }
+
+ // TODO The creation date doesn't exist yet. -dsb
+ const showCreatedAt = false
+ const createdAt = new Date()
+
+ // TODO Need to implement this. -dsb
+ const canLockGroupChat = false
+ // const canLockGroupChat = isOwner
+
+ return (
+ <>
+
+
+
+
+
+ {groupName}
+
+ {showCreatedAt ? (
+
+ Created {dateFormatter.format(createdAt)}
+
+ ) : null}
+
+
+ {isOwner ? (
+
+ ) : null}
+ {isJoinLinkEnabled ? (
+
+ ) : null}
+ {canLockGroupChat ? (
+
+ ) : null}
+ {isOwner ? null : isReportLinkEnabled ? (
+
+ ) : null}
+ {isOwner ? null : (
+
+ )}
+
+
+
+
+
+
+ >
+ )
+}
+
+function SettingsHeaderPlaceholder() {
+ const t = useTheme()
+
+ return (
+
+
+
+
+
+ …
+
+
+ …
+
+
+
+
+
+
+
+
+ )
+}
+
+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 (
+
+
+
+ …
+
+
+ )
+}
diff --git a/src/screens/Messages/ConversationSettings/prompts.tsx b/src/screens/Messages/ConversationSettings/prompts.tsx
new file mode 100644
index 0000000000..f5b980b990
--- /dev/null
+++ b/src/screens/Messages/ConversationSettings/prompts.tsx
@@ -0,0 +1,119 @@
+import {View} from 'react-native'
+import {Trans, useLingui} from '@lingui/react/macro'
+
+import {atoms as a} from '#/alf'
+import type * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import * as Prompt from '#/components/Prompt'
+
+export 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
+
+
+
+
+
+
+
+
+
+
+
+ >
+
+ )
+}
+
+export function LockChatPrompt({
+ control,
+ onConfirm,
+}: {
+ control: Dialog.DialogOuterProps['control']
+ onConfirm: () => void
+}) {
+ const {t: l} = useLingui()
+
+ return (
+
+ )
+}
+
+export function LeaveChatPrompt({
+ control,
+ groupName,
+ onConfirm,
+}: {
+ control: Dialog.DialogOuterProps['control']
+ groupName: string
+ onConfirm: () => void
+}) {
+ const {t: l} = useLingui()
+
+ return (
+
+ )
+}
+
+export function BlockMemberPrompt({
+ control,
+ onConfirm,
+}: {
+ control: Dialog.DialogOuterProps['control']
+ onConfirm: () => void
+}) {
+ const {t: l} = useLingui()
+
+ return (
+
+ )
+}