Tweak conversation settings (#10347)

This commit is contained in:
DS Boyce
2026-04-23 15:49:45 -07:00
committed by Eric Bailey
parent 2ebfac4fe2
commit 877d5b0d9c
10 changed files with 1273 additions and 1207 deletions
File diff suppressed because it is too large Load Diff
@@ -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 (
<>
<SubtleHoverWrapper>
<View style={[a.mx_xl]}>
<Pressable
accessibilityRole="button"
style={({pressed}) => [
a.flex_row,
a.align_center,
a.justify_between,
pressed && web({outline: 'none'}),
]}
onPress={addMembersControl.open}>
{({pressed}) => (
<>
<View>
<View style={[a.flex_row, a.align_center]}>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.p_lg,
a.rounded_full,
pressed
? t.atoms.bg_contrast_100
: t.atoms.bg_contrast_50,
{
height: 48,
width: 48,
},
]}>
<PlusIcon
style={[t.atoms.text_contrast_high]}
size="sm"
/>
</View>
<Text
style={[
a.text_md,
a.font_semi_bold,
a.pl_sm,
t.atoms.text,
]}>
<Trans>Add members</Trans>
</Text>
</View>
</View>
<ChevronIcon style={[t.atoms.text_contrast_medium]} size="md" />
</>
)}
</Pressable>
</View>
</SubtleHoverWrapper>
<Dialog.Outer
control={addMembersControl}
testID="addChatMembersDialog"
nativeOptions={{fullHeight: true}}>
<Dialog.Handle />
<AddMembersFlow
members={members}
title={l`Add members`}
onAddMembers={members => {
addGroupMembers({members})
}}
/>
</Dialog.Outer>
</>
)
}
@@ -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<NavigationProp>()
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 = <StatusBadge label={l`Admin`} />
}
} else {
statusBadge = (
<MemberMenu
convo={convo}
profile={profile}
displayName={displayName}
type={status}
isOwner={isOwner}
/>
)
}
return (
<SubtleHoverWrapper>
<View style={[a.flex_row, a.align_center, a.justify_between, a.mx_xl]}>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`View ${displayName}s profile`}
accessibilityHint={l`Opens this members profile`}
style={[a.flex_1, a.flex_row, a.align_center]}
onPress={() => {
navigation.navigate('Profile', {name: profile.handle})
}}>
<PreviewableUserAvatar
profile={profile}
size={48}
moderation={moderation.ui('avatar')}
/>
<View style={[a.mx_sm]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{displayName}
</Text>
<Text
style={[
a.text_xs,
{color: t.palette.contrast_500},
web(a.pt_2xs),
]}>
{sanitizeHandle(profile.handle, '@')}
</Text>
</View>
</Pressable>
<View>{statusBadge}</View>
</View>
</SubtleHoverWrapper>
)
}
@@ -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<bsky.profile.AnyProfileView>
type: 'owner' | 'standard' | 'invited'
displayName: string
isOwner: boolean
}) {
const navigation = useNavigation<NavigationProp>()
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: [
<Trans key="message">
Before you can message another user, you must first verify your email.
</Trans>,
],
})
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 (
<>
<Menu.Root>
<Menu.Trigger label={l`Open chat member options for ${displayName}`}>
{({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' ? (
<StatusBadge
label={type === 'owner' ? l`Admin` : l`Invited`}
pressableProps={triggerProps}
style={[
isActive
? {
backgroundColor: t.palette.contrast_0,
}
: null,
]}
/>
) : (
<Pressable
{...triggerProps}
style={[
a.rounded_full,
a.p_sm,
isActive
? {
backgroundColor: t.palette.contrast_0,
}
: null,
]}>
<EllipsisIcon
style={[t.atoms.text_contrast_medium]}
size="md"
/>
</Pressable>
)
}}
</Menu.Trigger>
<Menu.Outer>
<Menu.Group>
<Menu.Item
label={l`View ${displayName}s profile`}
onPress={() => {
navigation.navigate('Profile', {name: profile.did})
}}>
<Menu.ItemText>
<Trans>Go to profile</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={PersonIcon} />
</Menu.Item>
<Menu.Item
label={l`Message ${displayName}`}
onPress={handleMessageMember}>
<Menu.ItemText>
<Trans context="action">Message</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={MessageIcon} />
</Menu.Item>
</Menu.Group>
<Menu.Divider />
<Menu.Group>
{canBlockMember ? (
<Menu.Item
label={
profile.viewer?.blocking
? l`Unblock ${displayName}`
: l`Block ${displayName}`
}
onPress={
profile.viewer?.blocking
? handleBlockMember
: blockMemberPrompt.open
}>
<Menu.ItemText>
<Trans>Block</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={PersonXIcon} />
</Menu.Item>
) : null}
{canRemoveMember ? (
<Menu.Item
label={l`Remove ${displayName} from this group chat`}
onPress={() => removeMembers({members: [profile.did]})}>
<Menu.ItemText>
<Trans>Remove from chat</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
) : null}
{canUninviteMember ? (
<Menu.Item
label={l`Uninvite ${displayName} from this group chat`}
// TODO Need to wire up the uninvite flow. -dsb
onPress={() => {}}>
<Menu.ItemText>
<Trans>Uninvite</Trans>
</Menu.ItemText>
<Menu.ItemIcon icon={ArrowBoxLeftIcon} />
</Menu.Item>
) : null}
</Menu.Group>
</Menu.Outer>
</Menu.Root>
<BlockMemberPrompt
control={blockMemberPrompt}
onConfirm={() => void handleBlockMember()}
/>
</>
)
}
@@ -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 (
<View style={[a.flex_row, a.justify_between, a.mx_xl, a.mt_lg, a.mb_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_xs]}>
<Text style={[a.text_lg, a.font_semi_bold, t.atoms.text]}>
<Trans>Members</Trans>
</Text>
<Text
style={[a.text_xs, a.font_medium, {color: t.palette.contrast_500}]}>
{l({
message: `${memberCount}/${MEMBER_LIMIT}`,
comment:
'The number of group chat members out of the total number of permitted users.',
})}
</Text>
</View>
{isOwner && requestCount > 0 ? (
<InlineLinkText
label={l`View incoming group chat requests`}
style={[a.text_sm, a.text_right, a.font_semi_bold]}
// TODO Need to implement this. -dsb
to="#">
{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.',
})}
</InlineLinkText>
) : null}
</View>
)
}
@@ -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<ViewStyle>
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 = (
<Text style={[a.text_sm, a.font_semi_bold, t.atoms.text_contrast_medium]}>
{label}
</Text>
)
if (pressableProps) {
return (
<Pressable style={badgeStyle} {...pressableProps}>
{labelText}
</Pressable>
)
}
return <View style={badgeStyle}>{labelText}</View>
}
@@ -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<unknown>) {
const {
state: hover,
onIn: onHoverIn,
onOut: onHoverOut,
} = useInteractionState()
return (
<View
// Web-only
onPointerEnter={onHoverIn}
// Web-only
onPointerLeave={onHoverOut}
style={a.pointer}>
<SubtleHover hover={hover} />
{children}
</View>
)
}
@@ -0,0 +1,2 @@
export const ROW_SPACING = 20
export const MEMBER_LIMIT = 50
@@ -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 (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content align={gtTablet ? 'left' : 'platform'}>
<Layout.Header.TitleText>
<Trans>Group chat settings</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<ConvoProvider key={convoId} convoId={convoId}>
<SettingsInner convoId={convoId} />
</ConvoProvider>
</Layout.Screen>
)
}
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 (
<MembersAndRequests
memberCount={data.length}
requestCount={requestCount}
hasMoreRequests={!!hasMoreRequests}
isOwner={isOwner}
/>
)
case 'ADD_MEMBERS_LINK':
return convo ? (
<AddMembersLink
convo={convo}
members={data.map(profile => profile.did)}
/>
) : null
case 'CHAT_MEMBER':
return convo ? (
<Member
convo={convo}
profile={item.profile}
status={item.status}
isOwner={isOwner}
/>
) : null
default:
return null
}
}
if (convoState.status === ConvoStatus.Error) {
return (
<Error
title={l`Something went wrong`}
message={l`We couldnt load this conversations settings`}
onRetry={() => convoState.error.retry()}
sideBorders={false}
/>
)
}
return (
<List
data={items}
contentContainerStyle={{
gap: ROW_SPACING,
paddingBottom: bottomBarOffset + ROW_SPACING,
}}
desktopFixedHeight
initialNumToRender={initialNumToRender}
keyExtractor={keyExtractor}
ListHeaderComponent={
convo?.kind === 'group' ? (
<SettingsHeader convo={convo} isOwner={isOwner} />
) : (
<SettingsHeaderPlaceholder />
)
}
renderItem={renderItem}
sideBorders={false}
windowSize={11}
// TODO Paginate on relatedProfiles. -dsb
onEndReached={() => {}}
onEndReachedThreshold={IS_NATIVE ? 1.5 : 0}
/>
)
}
function SettingsHeader({
convo,
isOwner,
}: {
convo: Extract<ConvoWithDetails, {kind: 'group'}>
isOwner: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
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 (
<>
<View
style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}>
<View style={[a.align_center, a.justify_center]}>
<AvatarBubbles profiles={convo.members} />
</View>
<Text
style={[
a.text_2xl,
a.font_bold,
a.text_center,
a.pt_lg,
t.atoms.text,
]}>
{groupName}
</Text>
{showCreatedAt ? (
<Text
style={[
a.text_sm,
a.text_center,
a.pt_xs,
a.px_xl,
t.atoms.text_contrast_high,
]}>
<Trans>Created {dateFormatter.format(createdAt)}</Trans>
</Text>
) : null}
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.gap_2xl,
a.pt_2xl,
]}>
<SettingsButton
color={convo.view.muted ? 'negative_subtle' : 'secondary'}
icon={convo.view.muted ? BellOffIcon : BellIcon}
label={
convo.view.muted
? l`Unmute this group chat`
: l`Mute this group chat`
}
text={convo.view.muted ? l`Muted` : l`Mute`}
onPress={handleToggleMute}
/>
{isOwner ? (
<SettingsButton
icon={EditIcon}
label={l`Edit this group chats name`}
text={l`Edit name`}
onPress={handlePromptName}
/>
) : null}
{isJoinLinkEnabled ? (
<SettingsButton
icon={ChainLinkIcon}
label={
isOwner
? l`Create or modify an invite link for this group chat`
: l`View the invite link for this group chat`
}
text={l`Invite link`}
onPress={inviteLinkDialog.open}
/>
) : null}
{canLockGroupChat ? (
<SettingsButton
color={isLocked ? 'negative_subtle' : 'secondary'}
icon={LockIcon}
label={
isLocked ? l`Unlock this group chat` : l`Lock this group chat`
}
text={isLocked ? l`Locked` : l`Lock`}
onPress={isLocked ? handleUnlock : lockChatPrompt.open}
/>
) : null}
{isOwner ? null : isReportLinkEnabled ? (
<SettingsButton
color="secondary"
icon={FlagIcon}
label={l`Report this group chat`}
text={l`Report`}
onPress={handleReportChat}
/>
) : null}
{isOwner ? null : (
<SettingsButton
color="secondary"
icon={ArrowBoxLeftIcon}
label={l`Leave this group chat`}
text={l`Leave`}
onPress={leaveChatPrompt.open}
/>
)}
</View>
</View>
<EditNamePrompt
control={editNamePrompt}
value={newGroupName}
onChangeText={setNewGroupName}
onConfirm={handleEditName}
/>
<InviteLinkDialog
convo={convo}
control={inviteLinkDialog}
isOwner={isOwner}
/>
<LockChatPrompt control={lockChatPrompt} onConfirm={handleConfirmLock} />
<LeaveChatPrompt
control={leaveChatPrompt}
groupName={groupName}
onConfirm={leaveConvo}
/>
</>
)
}
function SettingsHeaderPlaceholder() {
const t = useTheme()
return (
<View style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}>
<View style={[a.align_center, a.justify_center]}>
<AvatarBubbles profiles={[]} />
</View>
<Text
style={[a.text_2xl, a.font_bold, a.text_center, a.pt_lg, t.atoms.text]}>
</Text>
<Text
style={[
a.text_sm,
a.text_center,
a.pt_xs,
a.px_xl,
t.atoms.text_contrast_high,
]}>
</Text>
<View
style={[
a.flex_row,
a.align_center,
a.justify_center,
a.gap_2xl,
a.pt_2xl,
]}>
<SettingsButtonPlaceholder />
<SettingsButtonPlaceholder />
<SettingsButtonPlaceholder />
<SettingsButtonPlaceholder />
</View>
</View>
)
}
function SettingsButton({
color = 'secondary',
icon,
label,
text,
onPress,
}: {
color?: ButtonColor
icon: React.ComponentType<SVGIconProps>
label: string
text: string
onPress: () => void
}) {
const t = useTheme()
return (
<View style={[a.align_center]}>
<Button
color={color}
size="large"
shape="round"
label={label}
onPress={onPress}>
<ButtonIcon icon={icon} size="md" />
</Button>
<Text
numberOfLines={1}
style={[
a.text_2xs,
a.font_medium,
a.text_center,
a.pt_xs,
t.atoms.text,
]}>
{text}
</Text>
</View>
)
}
function SettingsButtonPlaceholder() {
const t = useTheme()
const {t: l} = useLingui()
return (
<View>
<Button color="secondary" size="large" shape="round" label={l`Loading…`}>
<ButtonIcon icon={EllipsisIcon} size="md" />
</Button>
<Text
numberOfLines={1}
style={[
a.text_2xs,
a.font_medium,
a.text_center,
a.pt_xs,
t.atoms.text,
]}>
</Text>
</View>
)
}
@@ -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 (
<Prompt.Outer control={control}>
<>
<Prompt.Content>
<Prompt.TitleText>
<Trans>Edit group name</Trans>
</Prompt.TitleText>
<View style={[a.my_sm]}>
<TextField.Root isInvalid={false}>
<TextField.Input
label={l`Edit group name`}
placeholder={l`Group name`}
value={value}
onChangeText={onChangeText}
returnKeyType="done"
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
autoFocus
onSubmitEditing={onConfirm}
/>
</TextField.Root>
</View>
</Prompt.Content>
<Prompt.Actions>
<Prompt.Action cta={l`Save`} onPress={onConfirm} />
<Prompt.Cancel />
</Prompt.Actions>
</>
</Prompt.Outer>
)
}
export function LockChatPrompt({
control,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
onConfirm: () => void
}) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Lock group chat?`}
description={l`Members can still read chat history but cant send new messages.`}
confirmButtonCta={l`Lock group chat`}
cancelButtonCta={l`Cancel`}
onConfirm={onConfirm}
/>
)
}
export function LeaveChatPrompt({
control,
groupName,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
groupName: string
onConfirm: () => void
}) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Are you sure you want to leave ${groupName}?`}
description={l`You wont be able to rejoin unless youre invited.`}
confirmButtonCta={l`Leave group chat`}
confirmButtonColor="negative"
cancelButtonCta={l`Cancel`}
onConfirm={onConfirm}
/>
)
}
export function BlockMemberPrompt({
control,
onConfirm,
}: {
control: Dialog.DialogOuterProps['control']
onConfirm: () => void
}) {
const {t: l} = useLingui()
return (
<Prompt.Basic
control={control}
title={l`Block account?`}
description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`}
onConfirm={onConfirm}
confirmButtonCta={l`Block`}
confirmButtonColor="negative"
/>
)
}